Compare commits

..

124 Commits

Author SHA1 Message Date
Syngnat
6c98e98611 release/0.7.3 2026-04-30 17:42:08 +08:00
Syngnat
3c68325132 🐛 fix(oceanbase): 修复 Oracle 协议保存与连接链路
- 测试连接统一走 RPC 配置构造,确保 OceanBase Oracle 协议生效

- 保存连接时同步写入 oceanBaseProtocol 与 protocol 参数

- 编辑回显支持从显式字段、连接参数和 URI 恢复协议

- 双击连接时清理旧树缓存,避免复用 MySQL 协议子节点

- 补充 OceanBase 协议解析与缓存 key 隔离测试
2026-04-30 17:27:17 +08:00
Syngnat
5f9adcac37 🐛 fix(ai): 兼容 DeepSeek reasoning 内容响应
- 增加 reasoning_content 字段解析与前后端类型定义

- 兼容 DeepSeek 流式和非流式响应中的推理内容

- 统一 AI 消息 payload 映射,避免历史消息丢失推理内容

- 补充 OpenAI 兼容 Provider 与前端消息映射测试
2026-04-30 17:26:36 +08:00
Syngnat
d2dad75167 ♻️ refactor(oceanbase): 完善双协议连接链路
- 抽象 OceanBase 协议解析与运行态参数注入
- 复用 OracleDB 实现 OceanBase Oracle 租户连接能力
- 调整 DDL、schema、SQL 方言和数据源能力判断
- 补充协议优先级、缓存隔离和 RPC 参数测试
- 支持按指定 driver 自动生成 agent revision
2026-04-30 15:05:05 +08:00
Syngnat
98c62fd6bd 🎨 style(driver): 重做驱动管理页面布局与交互
- 页面结构:将驱动表格改为卡片列表,移除横向滚动依赖
- 信息展示:新增顶部状态统计,清晰区分全部、已启用、需重装、未启用
- 重装提示:将长原因文案收敛为摘要展示,并支持展开查看完整原因
- 操作优化:集中展示版本、进度、安装、重装、移除、本地导入和日志入口
- 响应式适配:窄屏下驱动卡片自动堆叠,避免内容挤压
2026-04-30 13:35:07 +08:00
Syngnat
7fd6d78c83 feat(driver): 新增 OceanBase 与 OpenGauss Agent 数据源
- 数据源支持:新增 OceanBase 与 OpenGauss optional driver-agent 实现
- 连接适配:复用 MySQL/PostgreSQL 兼容链路并补齐查询、DDL、同步能力
- 前端入口:补充连接表单、侧边栏、图标、SQL 方言和危险操作识别
- 驱动管理:更新 driver manifest、安装提示和 revision 自动生成链路
- 构建发布:支持多平台 driver-agent 打包并优化 release 构建失败提示
2026-04-30 13:13:01 +08:00
Syngnat
c92959f3e8 feat(connection): 支持多数据源额外连接参数配置
- 前端连接表单新增额外连接参数入口,支持 URI query 格式录入与解析回填
- MySQL 兼容驱动支持 JDBC 常见参数映射,修复 UTF-8 字符集与 serverTimezone 兼容问题
- 扩展 Oracle、PostgreSQL 兼容、SQL Server、ClickHouse、MongoDB、达梦、TDengine 参数合并
- 按不同驱动通道处理 DSN、URI、Options 与 Settings,避免统一透传导致连接异常
- 修复编辑已保存连接时解析无认证 URI 会清空已有账号密码的问题
- 补充连接参数透传、缓存隔离、DSN 合并与 URI 回填回归测试
2026-04-30 10:57:52 +08:00
Syngnat
c65e429072 🐛 fix(oracle): 兼容旧版本自动限行语法
- Oracle/Dameng 自动限行改为 ROWNUM 外层包裹
- 避免旧版本 Oracle 不支持 FETCH FIRST 导致 ORA-00933
- 保留尾部分号与注释,避免执行语句结构丢失
- 跳过 FOR UPDATE 语句自动包裹,避免改变锁语义
- 补充 Oracle/Dameng 自动限行回归测试
Refs #429
2026-04-30 08:33:24 +08:00
Syngnat
ca246725f3 release/0.7.2 2026-04-29 20:08:56 +08:00
Syngnat
c1ebce4ef5 feat(query-editor): 放宽单表查询结果列级编辑边界
- 查询编辑:支持简单表列与表达式列混合展示
- 编辑安全:仅允许真实表列编辑,表达式列保持只读
- 提交流程:支持结果列别名映射回真实表字段
- 测试覆盖:补充聚合查询静默只读与列级提交用例
2026-04-29 20:07:22 +08:00
Syngnat
2be09c1918 release/0.7.2 2026-04-29 17:34:59 +08:00
Syngnat
c927e33c8c feat(driver): 提醒重装旧版驱动代理
- optional-driver-agent 新增 metadata 方法返回 driverType、agentRevision 与协议版本
- 安装和本地导入驱动后记录 agentRevision,并在驱动状态中比对是否需要更新
- 驱动管理、连接表单和已有连接加载入口提示重装旧版 agent
- 补充旧 revision 检测和 custom 连接使用统计回归测试
2026-04-29 17:26:16 +08:00
Syngnat
824aafbdea 🔧 chore(driver): 自动生成驱动代理 revision
- 新增脚本按 optional driver-agent 源码依赖生成 revision 指纹
- 构建脚本与 dev/release workflow 在打包前自动刷新 revision
- 生成驱动 revision 映射并补充 optional driver 覆盖校验
2026-04-29 17:26:09 +08:00
Syngnat
0c1586d7a4 🐛 fix(clickhouse): 修复协议选择与连接错误提示
- 支持 ClickHouse 手动 HTTP/Native 协议优先级,避免 URI scheme 覆盖用户选择
- Auto 模式识别 Native/HTTP 协议误配错误并自动尝试备用协议
- 净化连接失败中的二进制乱码,补充测试连接参数校验和排查日志
- 前端表单增加 ClickHouse 协议选择并同步类型、缓存 key 与持久化兼容
Refs #425
2026-04-29 17:25:54 +08:00
Syngnat
b1ef52f62e feat(data-grid): 支持无主键表安全编辑
- 定位策略:新增主键、唯一索引和 Oracle ROWID 三类安全行定位能力
- 查询编辑器:简单单表 SELECT 自动补充隐藏定位列,复杂结果保持只读
- 表预览:无主键表可通过唯一索引或 Oracle ROWID 安全编辑
- 提交流程:移除无主键整行 WHERE fallback,隐藏定位列不参与展示和写入
- 后端保护:Oracle、MySQL、PostgreSQL 更新删除必须恰好影响 1 行
- 测试覆盖:补充 QueryEditor、DataViewer、DataGrid 和 ApplyChanges 相关用例
Refs #419
2026-04-29 12:33:35 +08:00
Syngnat
05a913ccb2 🐛 fix(query-editor): 修复多数据源大查询限流失效
- SQL限流:抽取查询自动限流工具,修复 SELECT 判断大小写不一致导致限制未生效
- 方言适配:按 Oracle/Dameng、SQL Server、MySQL/PostgreSQL 等方言分别注入行数限制
- 自定义驱动:支持 custom 连接根据 driver 解析 Oracle、PostgreSQL、SQL Server 等方言
- MongoDB修复:修正 db.collection.find() 解析边界,并对 find/只读 aggregate 下推 limit
- Oracle优化:DSN 增加 PREFETCH_ROWS 和 LOB FETCH 参数,减少大结果集拉取开销
- 测试覆盖:补充 SQL 方言矩阵、MongoDB 限流和 Oracle DSN 参数测试
Refs #424
2026-04-29 10:29:34 +08:00
Syngnat
f51dbcfb2c 🐛 fix(oracle): 修复查询结果编辑提交后数据还原
Oracle GetColumns 未返回主键列标记,前端 pkColumns 为空后退化为
全列 WHERE 条件,Oracle 空字符串即 NULL 语义导致 UPDATE 匹配 0 行。

LEFT JOIN all_constraints + all_cons_columns 检测主键列并赋值 Key="PRI",
与达梦驱动实现方式一致。
2026-04-29 09:41:25 +08:00
Syngnat
9792278fa3 release/0.7.1 2026-04-28 19:38:48 +08:00
Syngnat
5f7578c5ea feat(ai): 支持录制聊天发送快捷键
- 工具中心新增 AI 聊天发送快捷键,默认 Enter 并支持 Ctrl/Cmd/Alt+Enter
- AI 输入框按录制绑定发送,保留 Shift+Enter 换行和输入法 composing 保护
- 修复 shortcutOptions 启动刷新覆盖录制值的问题,并校验脏持久化快捷键
- 补充快捷键、输入框提示和持久化回归测试
- 撤回 macOS Caps Lock 浮层无效前端规避,恢复输入控件 no-auto-cap 属性
- 新增需求进度追踪文档记录验证结果
2026-04-28 18:12:42 +08:00
Syngnat
56eaca9081 🐛 fix(data-grid): 修复 schema 数据源 DDL 查看异常
- 表页入口:查看 DDL 不再依赖 dbName,支持金仓/PG 等 schema 数据源
- 标识符解析:新增 quote-safe qualified name 拆分,避免引号内点号被误拆
- DDL 兼容:PG、HighGo、VastBase 使用安全拆分处理 schema.table
- 自定义驱动:补齐 custom HighGo DDL 查询时的数据库上下文
- 测试覆盖:新增 schema 表、视图 fallback、dotted 标识符等回归用例
2026-04-28 14:57:52 +08:00
Syngnat
51675f9d05 🐛 fix(ai): 修复多方言执行与 DDL 降级
- SQL 执行:移除 AI 工具和代码块预览中硬编码的 LIMIT 50
- 方言适配:按连接类型和自定义驱动别名生成只读 SQL 预览限流语句
- Oracle 兼容:Oracle、自定义 Oracle 和达梦改用 ROWNUM 语法限制行数
- 权限降级:获取表 DDL 失败时自动降级为字段元数据摘要
- 上下文优化:手动添加表结构上下文时复用同一套 DDL 降级逻辑
- 测试覆盖:新增 AI SQL 限流和表结构降级单元测试
Refs #418
2026-04-28 14:03:48 +08:00
Syngnat
f5f87189df 🐛 fix(oracle): 修复查询结果编辑提交日期格式报错
- 参数处理:提交事务前加载 Oracle 表字段类型,用于识别 DATE 和 TIMESTAMP 字段
- 更新修复:UPDATE 的 SET 值和 WHERE 条件统一转换日期时间参数
- 场景覆盖:修复新建查询结果编辑后提交事务触发 ORA-01861 的问题
- 类型绑定:将 Oracle 日期时间字符串解析为 time.Time,避免依赖数据库会话日期格式
- 兼容处理:支持 RFC3339、带时区和常见本地日期时间格式
- 测试覆盖:新增 Oracle ApplyChanges recording driver 回归测试
Refs #419
2026-04-28 13:39:32 +08:00
Syngnat
ef634075ab 🐛 fix(external-sql): 修复外部 SQL 文件保存不写回源文件
- 保存逻辑:外部 SQL 文件标签页携带 filePath,保存时写回原始磁盘文件
- 后端接口:新增 WriteSQLFile 能力,支持覆盖已有 SQL 文件并保留原文件权限
- 状态隔离:外部文件保存失败时不创建 savedQuery,避免写入 localStorage 副本
- 兼容行为:非文件标签页继续沿用原有 savedQuery 快速保存逻辑
- 文案优化:将数据库下入口改为“外部 SQL 目录”,减少与单文件打开入口的歧义
- 测试覆盖:补充前端保存分支、后端写文件边界和外部 SQL 目录文案测试
Refs #422
2026-04-28 13:26:55 +08:00
Syngnat
a07eea7815 feat(data-grid): 新增表数据页 DDL 查看与当前页查找
- 表数据页新增查看 DDL 入口,支持直接打开只读 SQL 预览弹窗
- 当前页查找支持大小写不敏感高亮,仅作用于已加载数据和显示列
- 查找结果新增上一个、下一个导航,并自动聚焦选中匹配单元格
- DDL 请求增加上下文过期保护,避免切表后展示旧表结构
- 补充 DataGrid 布局、DDL 交互和查找工具函数单元测试
Refs #417
2026-04-28 12:39:51 +08:00
Syngnat
5886b1ded8 🔧 chore(frontend): 同步 package 校验文件 2026-04-28 10:36:07 +08:00
Syngnat
299a80dd5a 🐛 fix(frontend): 修复 macOS Caps Lock 输入浮层 2026-04-28 10:21:19 +08:00
Syngnat
225e9e61ed 🐛 fix(kingbase): 修复表操作标识符引用 2026-04-28 10:21:19 +08:00
Syngnat
fa4f2a938a 🐛 fix(jvm): 绑定前端变更执行到预览上下文
将 JVM 资源变更执行绑定到最近一次成功预览和连接配置指纹,并遮蔽敏感快照、payload 示例和 AI 上下文中的敏感值。
2026-04-28 09:42:48 +08:00
Syngnat
ec2eefc9d2 🐛 fix(jvm): 加固诊断命令策略与输出脱敏
在服务端阻断只读连接中的高风险和多行诊断命令,并对诊断事件与错误消息统一脱敏,避免凭证、Authorization 和 PEM 片段泄漏。
2026-04-28 09:42:41 +08:00
Syngnat
58ee269855 🐛 fix(jvm): 收紧 JMX domain allowlist 校验
在 helper runtime 中对直接 ObjectName、资源浏览、变更预览和监控路径统一执行 domain allowlist,阻断默认域别名和空白后缀绕过。
2026-04-28 09:42:29 +08:00
Syngnat
ffc4f2c2d9 🐛 fix(jvm): 强化变更确认令牌校验
将 JVM 变更确认从可重算校验值升级为服务端发放的一次性令牌,避免未预览、重放或上下文变更后继续执行高风险变更。
2026-04-28 09:42:21 +08:00
Syngnat
1b31c54917 🐛 fix(redis): 修复精确搜索无法命中命名空间
- 精确搜索识别无通配符的 Redis literal pattern
- 同时查询完整 Key 与同名命名空间前缀
- 修复输入 Agent 无法显示 Agent 文件夹的问题
- 避免误匹配 AgentCapacity、AgentState 等相似前缀
- 补充 glob literal 与命名空间搜索回归测试
- 更新 Redis 精确搜索输入提示文案
2026-04-27 11:31:20 +08:00
Syngnat
bd608cac46 Release/0.7.0 2026-04-26 20:56:39 +08:00
Syngnat
3665639300 🐛 fix(data-sync): 修复已保存连接同步时未恢复密文
- Data Sync 分析/预览/同步入口统一恢复源库和目标库连接密文
- 避免已保存 PostgreSQL 连接因空密码触发 28P01
- 保留前端选择的源/目标数据库覆盖值
- 增加保存连接密文恢复回归测试
Refs #413
2026-04-26 20:55:20 +08:00
Syngnat
3b9116e259 perf(table-overview): 优化大量表搜索渲染性能
- 预计算表概览搜索索引与排序键
- 使用 deferred value 降低搜索输入阻塞
- 限制大结果集首批渲染数量并支持继续加载
- 增加表概览过滤与渲染上限回归测试
2026-04-26 20:42:14 +08:00
Syngnat
a06f45da28 feat(redis): 新增 Key 精确搜索模式
- 增加 Redis Key 模糊/精确搜索切换
- 精确模式不再追加通配符并保留大小写敏感匹配
- 转义 Redis glob 特殊字符避免误匹配
- 补充搜索模式回归测试
2026-04-26 20:34:07 +08:00
Syngnat
21222cf9f4 🐛 fix(redis): 修复自动模式 JSON 大整数精度丢失
- 保留 Redis JSON 值中的大整数原始字面量
- 避免自动格式化时通过 JSON.stringify 改写超出安全范围的数字
- 补充自动模式大整数与字符串转义展示回归测试
Refs #400
2026-04-26 20:15:13 +08:00
Syngnat
30301cd637 feat(data-grid): 新增快速 WHERE 筛选输入与补全能力
- 新增表格筛选面板快速 WHERE 条件输入
- 支持字段、操作符和关键字自动补全
- 查询、分页统计与筛选导出合并快速 WHERE 条件
- 修复补全过程中的字段引号丢失和重复追加问题
Refs #354
2026-04-26 20:06:15 +08:00
Syngnat
55829bce86 🐛 fix(connection): 修复连接颜色重启丢失并同步标签页展示
- 恢复连接清洗流程中的图标类型与颜色字段
- 标签页增加连接色标识,便于区分多连接会话
- 抽取连接视觉解析并补充回归测试
Refs #334
2026-04-26 19:33:12 +08:00
Syngnat
2b340f3136 feat(data-grid): 增加复制行和粘贴行操作
- 表数据工具栏新增复制行、粘贴行按钮
- 支持将选中行复制为新增行草稿,提交前可继续检查编辑
- 抽离行复制粘贴数据构造逻辑并补充回归测试
Refs #332
2026-04-26 19:09:25 +08:00
Syngnat
9eb06f6f96 feat(data-grid): 增加复制行和粘贴行操作
- 表数据工具栏新增复制行、粘贴行按钮
- 支持将选中行复制为新增行草稿,提交前可继续检查编辑
- 抽离行复制粘贴数据构造逻辑并补充回归测试
Refs #332
2026-04-26 19:09:10 +08:00
Syngnat
01dd62f4e2 🐛 fix(table-designer): 去除 SQL 变更重复标记
- 移除 Monaco glyph margin 变更标记通道

- 仅保留 line decorations 左侧单一变更标记

- 补充防重复标记回归测试

Refs #324
2026-04-26 18:04:01 +08:00
Syngnat
09ecc841ab 🐛 fix(table-designer): 突出显示 SQL 变更行
- 识别新增、删除、重命名、属性修改等变更 SQL 行

- 使用 Monaco decorations 仅标记变更行,保留基础 SQL 语法高亮

- 补充变更行识别与装饰渲染回归测试

Refs #324
2026-04-26 17:53:22 +08:00
Syngnat
3a0c5201a0 feat(table-designer): 高亮显示 SQL 变更预览
- SQL 变更弹窗接入只读 SQL 高亮预览组件

- 注册明暗主题下的 SQL token 颜色

- 补充 SQL 预览高亮回归测试
2026-04-26 17:38:42 +08:00
Syngnat
5f6acc25da 🔧 chore(gitignore): 忽略 Playwright MCP 临时目录
- 将 .playwright-mcp/ 加入仓库忽略规则

- 避免本地浏览器工具临时文件进入提交状态
2026-04-26 17:24:45 +08:00
Syngnat
5bbeb7f373 feat(jvm/connection): 优化诊断工作台与连接配置体验
- JVM 诊断工作台改为会话优先布局,未建会话前隐藏命令输入

- 优化命令模板、实时输出、审计历史和能力检查卡片展示

- 连接配置表单引入按数据源分组的卡片化布局

- 补充连接配置布局和 JVM 诊断工作台回归测试
2026-04-26 17:18:10 +08:00
Syngnat
df4fcab90b 🐛 fix(sql): 适配多数据源 SQL 方言生成
- 表设计 DDL 按 Oracle/Dameng、SQL Server、PG-family、SQLite/DuckDB、ClickHouse/TDengine 分支生成

- 新增统一 SQL 方言工具,驱动字段类型候选和 SQL 自动补全

- 修复 Oracle/Dameng DATE/TIMESTAMP 删除条件字面量

- 补充多方言 DDL、补全和 Oracle 删除回归测试

Refs #402

Refs #409
2026-04-26 17:14:07 +08:00
Syngnat
f16e2f15c2 🐛 fix(jvm): 加固诊断与变更安全边界
- 诊断 SSE 支持空心跳事件,避免无输出时解码失败

- Arthas Tunnel 增加会话过期清理、配置漂移校验和取消兜底

- Provider 合约清理 Base URL 查询参数和片段,避免探测泄露敏感信息

- JVM 变更请求强制校验原因并规范化写入审计字段
2026-04-26 14:34:43 +08:00
Syngnat
38e71119a4 feat(jvm-diagnostic): 优化诊断控制台命令体验
- 诊断命令输入使用编辑器外观并支持 Arthas 命令补全

- 新增命令执行 pending 输出、前端终态兜底和历史刷新

- 会话、输出、历史记录统一展示中文语义状态

- 补充诊断控制台和补全展示测试
2026-04-26 14:34:23 +08:00
Syngnat
ff2b86819d feat(jvm-ui): 完善 JVM 工作台与监控入口
- 新增 JVM 持续监控仪表盘、图表、状态卡和详情面板

- 统一概览、资源浏览、审计页面的 JVM 工作台布局

- Sidebar 和 TabManager 支持监控入口、诊断入口兜底和上下文切换

- 补充前端状态模型、展示文案和组件回归测试
2026-04-26 14:34:02 +08:00
Syngnat
9d08b185d0 feat(jvm): 新增持续监控与采样链路
- 后端新增监控会话管理,支持启动、停止和历史查询

- JMX、Endpoint、Agent Provider 补齐监控快照采集能力

- JMX helper 增加内存、GC、线程、类加载采样并更新内嵌运行时

- 生成 Wails 监控接口绑定并补充后端回归测试
2026-04-26 14:33:41 +08:00
Syngnat
a43c84f968 🔧 chore(dev): 合并 JVM 缓存可视化编辑分支
- 合并 JVM 连接、资源治理、诊断控制台与 Arthas Tunnel 能力
- 合并测试版号统一与 macOS 无交互 ZIP 打包调整
- 基于最新 origin/dev 完成合并并通过前后端最小验证
2026-04-24 16:52:03 +08:00
Syngnat
14c6510835 🔧 fix(release/version): 对齐测试版号并移除Mac交互打包
- build-release 优先读取 GONAVI_VERSION 与 version/dev-version.txt
- 新增共享测试版号文件,统一开发态与发布脚本版本来源
- internal/app 版本解析增加 dev-version 回退与回归测试
- macOS 发布改为 ZIP 归档,不再触发 create-dmg 与 Finder 排版
- 补充发布脚本调整的需求追踪文档
2026-04-24 16:48:09 +08:00
Syngnat
6f14e827ab feat(jvm): 完成资源治理与诊断增强
- 新增 JMX/Endpoint/Agent 三种 JVM 连接模式与配置归一化链路
- 支持资源浏览、变更预览、写入应用、审计记录与只读约束
- 接入 AI 结构化写入计划与诊断计划回填能力
- 新增 Agent Bridge、Arthas Tunnel、JMX Helper 诊断传输实现
- 增加诊断控制台、命令模板、输出历史与自动补全交互
- 补齐前后端契约、运行夹具与 JVM 相关回归测试
2026-04-24 16:45:34 +08:00
Syngnat
d9b4c6a21b 🐛 fix(jvm): 固定 AI 重试链路的 JVM 上下文
- 为 JVM AI 回复的重新生成流程继承原始页签上下文并透传到新消息
- 让重试、催促重发和工具回合续跑都按原 JVM 上下文构建 system prompt
- 避免切换页签后重试 JVM 计划时出现上下文错位或定向能力丢失
- 重新通过前端全量测试、前端构建与 wails 生产构建验证
2026-04-23 13:40:29 +08:00
Syngnat
d2c3e3e779 🐛 fix(jvm): 修正 AI 计划映射与页签定向应用
- 为 JVM AI 计划补充显式草稿映射,避免 payload 包装层直接透传到现有变更契约
- 将 updateValue 映射为当前 JVM 写入链路的 put,并限制为 JSON 对象 payload
- 为 AI 聊天消息绑定 JVM 来源上下文,按 tab/connection/provider/resource 定向应用计划
- 补充 JVM AI 计划解析、契约映射和目标页签解析单测
- 更新需求追踪并回填 go test、前端测试、构建与 wails build 验证结果
2026-04-23 13:02:04 +08:00
Syngnat
3cb2d494cc feat(jvm): 接入 AI 结构化变更计划
- 新增 JVM AI 计划解析器与 fenced json 契约测试
- 为 JVM 资源页注入 AI 计划生成 prompt 并支持回填草稿
- 在 AI 对话上下文中补充 JVM 资源约束与应用入口
2026-04-23 12:42:02 +08:00
Syngnat
9a61622568 feat(jvm): 增加 JVM 写入预览与审计
- 打通 JVM 变更预览、执行确认与审计记录链路
- 增加 Guard 校验、模式约束与审计写入失败回传
- 补齐审计页签、预览弹窗和 Task 5 回归覆盖
2026-04-23 12:14:36 +08:00
Syngnat
21f2b29d1d feat(jvm): 打通 JVM 只读资源浏览链路
- 后端新增 JVMListResources 与 JVMGetValue 接口并补齐回归测试
- Sidebar 基于能力探测展示 JVM 模式节点并懒加载资源节点
- TabManager 接入 JVMOverview、JVMResourceBrowser 与模式徽标展示
- 补齐 JVM Tab 元数据与连接持久化 sanitize 逻辑
- 更新需求追踪文档并记录 Task 4 验证结果与残余风险
2026-04-23 11:21:36 +08:00
Syngnat
7ddb49a81d 🐛 fix(jvm): 修正连接表单模式回填与超时同步
- 保留编辑态 JVM 连接的原始 preferredMode,避免旧配置被静默降级
- 将 JVM 可见超时统一同步到 Endpoint 探测配置
- 抽取 JVM 可编辑模式判定与回填逻辑,统一 ConnectionModal 行为
- 补充 JVM 模式与超时纯函数测试,覆盖 unsupported preferredMode 分支
- 更新需求追踪文档,记录 Task 3 实现、复审与验证结果
2026-04-23 10:20:47 +08:00
Syngnat
9bb7ece2dd 🐛 fix(frontend):收敛JVM模式选项与标题文案 2026-04-23 09:42:37 +08:00
Syngnat
177dafacc9 feat(frontend):接入JVM连接表单与展示元数据 2026-04-23 09:23:28 +08:00
Syngnat
03a1506686 feat(jvm): 增加连接测试与能力探测 API
- 新增 JVM provider 工厂与 JMX、Endpoint 骨架实现
- 暴露 TestJVMConnection 和 JVMProbeCapabilities 并统一 QueryResult 返回
- 刷新 Wails 绑定与 JVM 连接模型,补齐前后端方法签名
- 补充 App 编排测试与 provider 契约测试,避免假成功和静默成功
- 更新需求追踪,记录 Task 2 审查结论与验证证据
2026-04-22 17:52:28 +08:00
Syngnat
15b1ad24d1 feat(jvm): 落地 JVM 连接契约与配置归一化
- 新增 JVM 连接配置与共享 DTO,补齐 JMX 和 Endpoint 契约
- 实现后端归一化规则,支持默认只读、模式回退和 JMX 端口兜底
- 新增前端 JVM 默认值与配置构建工具,统一模式环境和端口收敛
- 补充 Go 与 Vitest 用例并更新需求追踪,记录 Task 1 验证证据
2026-04-22 17:20:00 +08:00
Syngnat
f584270209 📝 docs(jvm): 沉淀 JVM Connector MVP 实施计划
- 按 Task 拆分连接契约、Provider、前端工作台与 AI 集成实现路径
- 明确前后端文件边界、TDD 顺序、Wails 绑定刷新与回归命令
- 补齐共享 DTO、provider factory 和审计落盘等关键实现细节
- 同步需求追踪进入实施计划阶段
2026-04-22 16:50:40 +08:00
Syngnat
fe9d02734f 📝 docs(jvm): 沉淀 JVM 缓存可视化编辑设计
- 新增 JVM Connector 的统一入口、多 Provider 与能力协商方案
- 明确 JMX 与 Management Endpoint 为 MVP,Agent 仅保留扩展位
- 定义资源模型、AI 协同、Guard Layer、审计与分期边界
- 同步需求追踪中的范围、风险、决策与验证记录
2026-04-22 16:50:40 +08:00
Syngnat
b9ac1ab9b7 合并拉取请求 #396
release/0.6.9
2026-04-17 21:17:38 +08:00
Syngnat
65a9f4352e feat(sql-files): 支持外部 SQL 目录树与双击打开
- 新增 SQL 目录选择、枚举与按路径读取接口,复用大文件执行能力
- Sidebar 增加外部 SQL 文件目录树、目录管理入口与双击打开查询标签
- 补充 external SQL 持久化与前后端回归测试

Fixes #319
2026-04-17 21:02:48 +08:00
Syngnat
f3b78f9763 🐛 fix(driver): 明确JDBC Jar导入限制并补充Kingbase指引
- 后端在驱动包选择与本地导入前拦截 JDBC Jar,并返回替代说明
- 驱动管理统一改为“导入驱动包”,补充不支持 JDBC Jar 的提示
- 自定义连接补充 kingbase8 等驱动别名与 Go 驱动说明
- 新增后端与前端回归测试

Refs #317
2026-04-17 20:41:58 +08:00
Syngnat
0bccdeed8c feat(ui): 优化侧边栏设置中心与数据表交互
- 收敛左上角入口为工具和设置中心,并调整新建连接操作优先级
- 优化表设计器 SQL 预览高亮和刷新前未保存字段变更确认
- 下移数据页次级操作并将编辑行收口到单元格右键菜单
- 补充侧边栏布局、表设计器草稿检测和数据页布局回归测试

Refs #324
2026-04-17 20:09:46 +08:00
Syngnat
39f6fbbe1f 🐛 fix(export): 修正带注释的 JOIN 查询结果导出校验
- 导出前缀判断增加前置 SQL 注释清理,避免合法 SELECT 被误判
- ExportQuery 统一复用 looksLikeSelectOrWith 逻辑,消除重复校验分支
- 补充带前置注释的 INNER JOIN 导出回归测试

Fixes #391
2026-04-17 19:01:39 +08:00
Syngnat
8a1a9a8fb8 🐛 fix(mongodb): 支持 Mongo shell 快捷查询命令
- 为 show dbs 和 show databases 转换 listDatabases JSON 命令
- 为 show collections 和 show tables 转换 listCollections JSON 命令
- 补充 Mongo shell 快捷命令回归测试并验证前端构建

Fixes #390
2026-04-17 18:56:01 +08:00
Syngnat
dca5f629b2 🐛 fix(dameng): 修正表格更新无法识别主键列
- 达梦列元数据查询补充主键关联并返回 column_key
- GetColumns 正确映射主键标记,避免表格更新退化为整行 WHERE
- 补充达梦列元数据回归测试,并验证带驱动 tag 的实现编译通过

Fixes #389
2026-04-17 18:42:47 +08:00
Syngnat
8eae39c2c2 🐛 fix(redis-viewer): 修正 Redis 值自动与 UTF-8 展示不一致
- 新增 redisValueDisplay 工具,统一自动、UTF-8 与十六进制模式的展示判断
- 修正已解码 Unicode 文本被重复按字节解码导致的乱码问题
- 补充 Redis 值展示回归测试,并让各数据类型复用同一套展示逻辑

Fixes #386
2026-04-17 18:31:04 +08:00
Syngnat
9613b2a8eb 🐛 fix(window): 修正启动窗口恢复到不可见区域
- 启动恢复普通窗口时先校验持久化 bounds 是否仍与可视区域相交
- 完全掉出可视区域时自动回正并回写新的窗口位置到 store
- 补充窗口恢复 helper 回归测试并验证前端构建通过

Fixes #384
2026-04-17 18:19:42 +08:00
Syngnat
4fd679ce42 🐛 fix(sqlserver): 修正 uniqueidentifier 展示为十六进制字节
- 查询值规整新增 uniqueidentifier 识别并复用 go-mssqldb GUID 格式化
- 避免 SQL Server 查询结果把 GUID 展示为原始 0x 字节串
- 补充 uniqueidentifier 原始字节回归测试并覆盖驱动返回值路径

Fixes #381
2026-04-17 18:10:51 +08:00
Syngnat
e56a72eb9f 🐛 fix(redis): 修正 hash 详情读取依赖 HGETALL
- 为 hash 读取增加 HGETALL 权限受限时的 HSCAN 降级路径
- RedisGetValue 与 GetHash 统一复用 fallback 并保留长度元数据
- 补充普通用户权限受限与非权限错误回归测试

Fixes #380
2026-04-17 18:07:50 +08:00
Syngnat
0fda09a19f 🔧 chore(dev): 合并 open issue backlog 修复分支
- 合并已按 issue 拆分提交的 backlog 修复与 SQL 结果集同步能力
- 解决 DataGrid、Sidebar 以及 legacy WebKit 存储迁移测试的合并冲突
- 保留 dev 分支当前结构并移除已废弃的 issue backlog 跟踪文档
2026-04-17 17:52:14 +08:00
Syngnat
33b78fb583 🐛 fix(sync): 同步 SQL 结果集同步前端模型绑定
- 为数据同步请求模型补齐 sourceQuery 字段生成声明
- 使前端生成绑定与 SQL 结果集同步后端参数保持一致
- 补齐 issue #321 功能收尾所需的 Wails 模型产物

Fixes #321
2026-04-17 17:45:10 +08:00
Syngnat
40416fb4df 🐛 fix(redis): 同步 hash 字段删除接口前端绑定
- 同步 Wails 前端声明中的 RedisDeleteHashField 参数类型
- 使生成绑定与后端兼容字符串和数组入参的实现保持一致
- 补齐 issue #343 修复后的前端接口声明

Fixes #343
2026-04-17 17:45:05 +08:00
Syngnat
651eec1617 feat(sync): 新增 SQL 结果集数据同步能力
- 同步引擎新增查询结果集同步分支,支持单目标表差异分析、预览与执行
- 数据同步工作台增加 SQL 结果集模式,并补充目标表与查询校验
- 补充后端同步链路与前端请求构造回归测试,并更新 backlog 记录

Fixes #321
2026-04-17 16:31:55 +08:00
Syngnat
9dc58acb39 🐛 fix(table-designer): 修正 MySQL 列改名预览 SQL
- 将 MySQL 列改名从 MODIFY COLUMN 切换为 CHANGE COLUMN 语法
- 保留类型变更与列位置子句的既有生成逻辑
- 补充回归测试并更新 issue backlog 记录

Fixes #373
2026-04-17 15:06:09 +08:00
Syngnat
f3193f0933 🐛 fix(ai): 修正 SQL 代码块 Markdown 换行渲染
- 为 AI markdown 渲染补充 fenced code block 预处理
- 修正 opening/closing fence 缺少换行时的代码块解析失败
- 补充回归测试并更新 issue backlog 记录

Fixes #369
2026-04-17 14:37:36 +08:00
Syngnat
7cb46f9f69 🐛 fix(window): 修正最大化窗口恢复焦点后重复动画
- 收敛 Windows 最大化窗口的激活修复逻辑,避免返回前台时重复 toggle
- 标题栏按钮按窗口状态切换 maximize/restore 图标并立即同步 store
- 补充窗口状态规则测试并更新 issue backlog 记录

Fixes #368
2026-04-17 14:18:38 +08:00
Syngnat
04c4613e4d 🐛 fix(datagrid): 修正日期字段设置值被误存为 NULL
- 抽取时间字段保存 helper 并统一 picker 类型与格式化逻辑
- 单元格保存优先使用 picker 实时值,避免 Form 同步滞后把日期误判为空
- 补充前端回归测试并更新 issue backlog 记录

Fixes #363
2026-04-17 13:46:38 +08:00
Syngnat
8a10519f9b 🐛 fix(query): 修正新建查询未引用 PostgreSQL 大写表名
- 抽取表查询模板 helper 并统一复用方言标识符引用逻辑
- 修正 Sidebar 与 TableOverview 的表节点新建查询入口
- 补充前端回归测试并更新 issue backlog 记录

Fixes #349
2026-04-17 13:30:07 +08:00
Syngnat
d57081ecfb 🐛 fix(query): 修正查询结果同名列被覆盖问题
- 为查询结果扫描增加稳定列名归一化,重复列自动追加序号后缀
- 统一返回字段列表与行数据键名,避免同名列值被后写覆盖
- 补充 scanRows 回归测试并更新 issue backlog 记录

Fixes #348
2026-04-17 13:24:50 +08:00
Syngnat
035f536e8d 🐛 fix(tdengine): 补齐超级表元数据查询
- 表列表合并 SHOW TABLES 与 SHOW STABLES 结果
- 返回前统一去重并排序,确保超级表可见
- 增加 TDEngine 表列表回归测试

Fixes #346
2026-04-17 13:14:08 +08:00
Syngnat
22e4299d3e 🐛 fix(redis): 修正 hash 字段删除参数序列化错误
- 前端统一按数组传递 hash 字段删除参数
- 后端兼容单字符串与数组两种删除入参
- 补充 Redis hash 字段删除回归测试

Fixes #343
2026-04-17 12:45:21 +08:00
Syngnat
384aea132c 🐛 fix(sync): 修正仅同步结构未生效
- 让已存在目标表场景复用通用补字段逻辑生成结构变更 SQL
- 为分析与预览结果补充结构差异计数与结构 SQL 明细
- 补充结构同步回归测试并更新 backlog 记录

Fixes #342
2026-04-17 12:35:23 +08:00
Syngnat
890478eb7b 🐛 fix(clickhouse): 修正 8132 端口连接失败
- 将 8132 纳入 ClickHouse HTTP 端口识别范围
- 同步修正协议切换日志与错误提示中的端口说明
- 补充连接协议识别回归测试并更新 backlog 记录

Fixes #338
2026-04-17 12:27:20 +08:00
Syngnat
8c79f2af0c 🐛 fix(update): 修正 Linux 变体自动更新失效
- 更新资产选择逻辑按当前 Linux 可执行文件变体匹配 release 包
- Linux 更新脚本优先查找与当前二进制同名的新文件
- 补充自动更新回归测试并更新 backlog 记录

Fixes #337
2026-04-17 12:17:11 +08:00
Syngnat
a2cad9f7ce 🐛 fix(ai): 修正 Anthropic 兼容供应商问答失败
- 为 AnthropicProvider.Chat 与 ChatStream 补充工具调用降级回退
- 首次携带 tools 请求在 400/422/404 时自动去掉 tools 重试一次
- 补充兼容供应商问答回归测试并更新 backlog 记录

Fixes #333
2026-04-17 12:02:23 +08:00
Syngnat
af90936fcc 🐛 fix(frontend): 修复 Redis 搜索匹配与输入交互体验
- Redis Key 搜索默认补全包含匹配并支持 ASCII 大小写不敏感
- Redis 标签页增加连接名与 host 摘要,区分同名 db 标签
- 抽取 inputAutoCap、redisSearchPattern、tabDisplay 共享工具并补充回归测试
- 覆盖连接配置、Redis 搜索、表设计、表概览和数据表筛选输入的自动纠正问题
- 在 macOS 文本输入面板关闭局部毛玻璃,修复输入法切换出现透明框
2026-04-16 18:07:38 +08:00
Syngnat
d3a1c017da 🐛 fix(driver): 修复可选驱动在线安装回归问题
Refs #388

- 修复 builtin 默认安装版本判定错误
- 恢复驱动总包 bundle 兜底路径
- 优化 Kingbase 安装策略,避免发行版优先本地构建
- 增强驱动安装日志与回归测试
2026-04-16 15:05:16 +08:00
Syngnat
a90423c04c Merge pull request #385 from Jonclex/dev 2026-04-15 15:17:46 +08:00
Jonclex
6e23053ac6 Merge branch 'Syngnat:dev' into dev 2026-04-15 14:47:18 +08:00
jonclex
9b50e9c9c8 fix(custom+mysql):CustomDB(driver=mysql)路径没有同步修改,custom链接打开时schema报错,双击表查询报错 refs bug#385 2026-04-15 14:42:45 +08:00
Syngnat
4c76202d2c Merge pull request #382 from anyanfei/feature/add_import_xml_dev 2026-04-15 13:50:49 +08:00
anyanfei
9c5b1a033a fix(import connect):统一测试用例文案;仅判断mysql-workbench-xml 2026-04-15 13:40:17 +08:00
jonclex
c631feef91 fix(ui): 表概览排除视图 refs bug#375 2026-04-15 10:27:22 +08:00
jonclex
737896627a fix(mysql): 表列表排除视图 refs bug#375 2026-04-15 10:06:44 +08:00
anyanfei
47235e1390 fix(import connect):修改导入时提示,而不是在连接时提示 2026-04-15 09:53:29 +08:00
anyanfei
b6121fe1f8 - 背景与问题 :以前没有支持官方工具mysqlworkbench的xml导入,现在支持了
- 变更点:新增mysqlworkbench的xml文件导入,并当没有密码时,提示用户,而不是直接使用空密码进行直接连接,更友好
  - 影响范围:仅导入受到影响
  - 验证方式:点击导入,用mysqlworkbench的xml进行导入即可
2026-04-14 18:50:40 +08:00
Syngnat
f78b132c7c 修改mysql编辑视图的时候保存失败。 (#377) 2026-04-13 17:13:08 +08:00
Jonclex
1adef17366 Merge branch 'Syngnat:dev' into dev 2026-04-13 15:49:53 +08:00
jonclex
ada9bbf03e fix(mysql): 修复视图编辑时的DDL头部兼容 2026-04-13 15:39:08 +08:00
Syngnat
266f217bfd 合并拉取请求 #371
fix: Oracle/DM数据库侧边栏视图不显示和默认模式显示问题
2026-04-13 12:51:42 +08:00
Syngnat
797db8cd36 release/0.6.8 (#372) 2026-04-13 12:51:16 +08:00
Jonclex
54d46453df Merge branch 'Syngnat:dev' into dev 2026-04-13 12:41:53 +08:00
Syngnat
c7cf9526de 🐛 fix(security): 修复 macOS 无法打开应用及三平台依赖系统钥匙串的问题
- 密文存储:新增 dailysecret 本地存储引擎,连接/代理/AI 密钥不再依赖系统钥匙串
- 启动迁移:自动将已有钥匙串密文迁移到本地 JSON,用户无感知
- WebKit 迁移:从旧版 Wails WebKit LocalStorage 中恢复连接与代理数据
- DMG 修复:移除 --sandbox-safe 避免扩展属性污染签名,新增 xattr 清理与签名校验
- 安全适配:钥匙串不可用时标记完成而非回滚,消除无钥匙串环境下的阻塞
- 出口脱敏:所有连接/代理 API 返回前统一 sanitize 防止密文泄漏
2026-04-13 12:40:25 +08:00
jonclex
d849cd49af fix: Oracle/DM数据库侧边栏不显示'默认模式'节点 2026-04-13 12:39:14 +08:00
Syngnat
604aaad69d 合并拉取请求 #366
fix(sidebar): normalize mysql view names (#365)
2026-04-13 10:35:00 +08:00
jonclex
605e266eab fix(sidebar): normalize mysql view names (#365) 2026-04-13 10:25:03 +08:00
Syngnat
bf5a9c3306 合并拉取请求 #362
release/0.6.7
2026-04-12 12:51:23 +08:00
辣条
2569a3779a feat(connection-package): 支持连接恢复包双模式加密导入导出 (#361)
## 变更说明

  - 为连接恢复包新增 v2 双模式加密导入导出
  - 默认使用应用内置密钥加密 secrets 字段,无需用户输入文件密码
  - 可选增加文件保护密码,形成双层加密
  - 保留 v1 导入兼容,并兼容 legacy JSON 明文导入
  - 前端新增 v2 格式识别与导出弹窗选项适配
  - 合并过程中已处理与最新 dev 的冲突,确保现有安全更新链路不被破坏

  ## 回归验证

  - `go test -count=1 ./...`
  - `npm test`
  - `npm run build`
- `wails build -platform windows/amd64 -clean -o
GoNavi-windows-amd64-test -ldflags "-s -w -X
GoNavi-Wails/internal/app.AppVersion=dev-d150780-merge-test"`

  ## 人工验证

  - 明文配置导入通过
  - v2 无文件密码导出后可直接导入
  - v2 带文件密码导出后需密码导入
  - 导入旧版恢复包与 legacy JSON 均正常

  ## 备注

  - 本 PR 基于最新 `dev` 进行冲突整合
2026-04-12 12:48:47 +08:00
Syngnat
bb6271246b 🐛 fix(mac): 禁用正式包默认窗口诊断以规避启动无窗体问题
- 将 macOS 原生窗口诊断改为默认关闭
- 仅在显式设置 GONAVI_ENABLE_MAC_WINDOW_DIAGNOSTICS 时启用后端诊断
- 仅在前端开发环境启用窗口诊断采集
- 避免正式构建在启动阶段附加额外窗口状态探测与日志观察
- 为诊断开关补充前后端最小回归测试

Refs: #360
2026-04-12 12:46:15 +08:00
Syngnat
8e0d1b0a80 📝 docs(contributing): 修正 dev 分支贡献流程说明
- 修正文档中的默认分支与集成分支描述
- 调整贡献分支创建基线为 dev
- 调整外部 Pull Request 目标分支为 dev
- 同步 README 中英文贡献说明
- 更新 release 后 main 回流 dev 的维护说明

Refs: #352
2026-04-12 12:34:50 +08:00
tianqijiuyun-latiao
d150780879 Merge branch 'feature/20260408_security-update' into merge/feature-20260408-security-update-onto-dev
# Conflicts:
#	frontend/src/App.tsx
#	frontend/wailsjs/go/app/App.d.ts
#	frontend/wailsjs/go/app/App.js
2026-04-12 09:40:28 +08:00
tianqijiuyun-latiao
52d2ee7592 feat(connection-package): 支持连接恢复包双模式加密导入导出
- 新增 v2 连接恢复包 appKey 与文件密码双模式加密链路
- 扩展前后端导入导出流程并兼容 v1 与 legacy 格式
- 修复无文件密码恢复包导入误弹密码框导致的流程阻塞
2026-04-11 23:51:43 +08:00
tianqijiuyun-latiao
1751e14d20 🐛 fix(security): 修复安全更新重检卡死与 Redis 密文兼容 2026-04-11 20:12:23 +08:00
tianqijiuyun-latiao
82e06bd94d 🐛 fix(security): 完善密文升级导入覆盖与安全更新链路
- 完善连接恢复包与 legacy 导入覆盖语义及密文兼容处理

- 修复安全更新详情高亮反馈与相关前后端链路

- 补强 keyring 误判边界与安全更新回归测试
2026-04-11 16:53:03 +08:00
tianqijiuyun-latiao
070ff72ad8 feat(security): 完成密文升级与连接恢复包导入导出 2026-04-10 21:29:45 +08:00
tianqijiuyun-latiao
1a042321d2 🐛 fix(connection): 修复失败连接高频重试并暂停后台自动元数据拉取 #331
- 后端为失败数据库连接增加冷却窗口,避免短时间内重复真实建连
- 补充失败冷却回归测试,覆盖重复失败、冷却后重试和成功后清理场景
- 前端在后台态暂停查询页、侧边栏和表概览的自动元数据拉取
- 保持手动刷新、手动展开等显式操作行为不变
2026-04-08 22:31:50 +08:00
390 changed files with 70018 additions and 6728 deletions

View File

@@ -246,6 +246,7 @@ jobs:
run: |
set -euo pipefail
DEV_VERSION="${{ steps.version.outputs.version }}"
./tools/generate-driver-agent-revisions.sh --platform "${{ matrix.platform }}"
if [ -n "${{ matrix.wails_tags }}" ]; then
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${DEV_VERSION}"
else
@@ -260,7 +261,7 @@ jobs:
TARGET_PLATFORM="${{ matrix.platform }}"
GOOS="${TARGET_PLATFORM%%/*}"
GOARCH="${TARGET_PLATFORM##*/}"
DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
DRIVERS=(mariadb oceanbase doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss mongodb tdengine clickhouse)
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
@@ -320,6 +321,9 @@ jobs:
echo " macOS 产物不执行 UPX 压缩,保留原始主程序。"
echo "🔏 正在进行 Ad-hoc 签名..."
if command -v xattr >/dev/null 2>&1; then
xattr -cr "$APP_NAME" || true
fi
codesign --force --deep --sign - "$APP_NAME"
DMG_NAME="${{ matrix.build_name }}.dmg"
@@ -336,6 +340,17 @@ jobs:
--app-drop-link 600 185 \
"$DMG_NAME" \
"$APP_NAME"
VERIFY_MOUNT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gonavi-dev-verify.XXXXXX")
hdiutil attach -nobrowse -readonly -mountpoint "$VERIFY_MOUNT_DIR" "$DMG_NAME" >/dev/null
PACKAGED_APP=$(find "$VERIFY_MOUNT_DIR" -maxdepth 1 -name "*.app" | head -n 1)
if [ -z "$PACKAGED_APP" ]; then
echo "❌ DMG 内未找到 .app 应用包!"
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
exit 1
fi
codesign --verify --deep --strict --verbose=4 "$PACKAGED_APP"
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
mv "$DMG_NAME" "../../$FINAL_NAME"

View File

@@ -237,6 +237,7 @@ jobs:
shell: bash
run: |
set -euo pipefail
./tools/generate-driver-agent-revisions.sh --platform "${{ matrix.platform }}"
if [ -n "${{ matrix.wails_tags }}" ]; then
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
else
@@ -251,7 +252,7 @@ jobs:
TARGET_PLATFORM="${{ matrix.platform }}"
GOOS="${TARGET_PLATFORM%%/*}"
GOARCH="${TARGET_PLATFORM##*/}"
DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
DRIVERS=(mariadb oceanbase doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss mongodb tdengine clickhouse)
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
@@ -314,6 +315,9 @@ jobs:
echo "🔏 正在进行 Ad-hoc 签名..."
# 注意Ad-hoc + hardened runtime--options runtime在未配置 entitlements 时,
# 可能导致部分 macOS 机型上应用双击无响应。这里保持 Ad-hoc 深签名但禁用 runtime hardened。
if command -v xattr >/dev/null 2>&1; then
xattr -cr "$APP_NAME" || true
fi
codesign --force --deep --sign - "$APP_NAME"
DMG_NAME="${{ matrix.build_name }}.dmg"
@@ -330,6 +334,17 @@ jobs:
--app-drop-link 600 185 \
"$DMG_NAME" \
"$APP_NAME"
VERIFY_MOUNT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gonavi-release-verify.XXXXXX")
hdiutil attach -nobrowse -readonly -mountpoint "$VERIFY_MOUNT_DIR" "$DMG_NAME" >/dev/null
PACKAGED_APP=$(find "$VERIFY_MOUNT_DIR" -maxdepth 1 -name "*.app" | head -n 1)
if [ -z "$PACKAGED_APP" ]; then
echo "❌ DMG 内未找到 .app 应用包!"
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
exit 1
fi
codesign --verify --deep --strict --verbose=4 "$PACKAGED_APP"
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
mv "$DMG_NAME" "../../$FINAL_NAME"

6
.gitignore vendored
View File

@@ -1,7 +1,7 @@
# IDE
.idea/
*.iml
.gitignore
# build / release artifacts
frontend/release/
**/release/
@@ -20,6 +20,7 @@ GoNavi-Wails.exe
.superpowers/
.claude/
.gemini/
.playwright-mcp/
**/tmpclaude-*
docs/superpowers/
docs/需求追踪/
@@ -27,4 +28,5 @@ docs/需求追踪/
CLAUDE.md
**/CLAUDE.md
.worktrees
docs
docs
.tmp_superpowers_edit

View File

@@ -2,14 +2,14 @@
Thank you for contributing to this project.
This repository follows a release-first workflow: `main` is the default public branch, while releases are prepared through `release/*` branches.
This repository uses `dev` as the default integration branch, while stable releases are published from `main` through `release/*` branches.
---
## Branch Model
- `main`: stable release branch and default branch
- `dev`: day-to-day integration branch for maintainers
- `dev`: default branch and day-to-day integration branch
- `main`: stable release branch
- `release/*`: release preparation branches for maintainers
- Recommended branch names for external contributors:
- `fix/*`: bug fixes
@@ -25,21 +25,21 @@ feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
## How External Contributors Should Open Pull Requests
Whether your branch is `fix/*` or `feature/*`, external contributors should **open pull requests directly against `main`**.
Whether your branch is `fix/*` or `feature/*`, external contributors should **open pull requests directly against `dev`**.
Reasons:
- `main` is the default branch, so the PR entry point is clearer
- merged contributions are immediately visible on the default branch
- maintainers can handle downstream sync and release preparation in one place
- `dev` is the active integration branch, so changes can be reviewed in the same lane as ongoing work
- contributors align with the branch that triggers day-to-day validation and dev builds
- maintainers can cut `release/*` branches from `dev` without re-syncing external changes first
Recommended flow:
1. Fork this repository
2. Create a branch in your fork (`fix/*` or `feature/*` is recommended)
2. Sync your fork with `dev` and create a branch from `dev` (`fix/*` or `feature/*` is recommended)
3. Make your changes and perform basic self-checks
4. Push the branch to your fork
5. Open a pull request against the `main` branch of this repository
5. Open a pull request against the `dev` branch of this repository
---
@@ -63,33 +63,21 @@ Recommended expectations:
## Merge Strategy for Maintainers
Pull requests merged into `main` should generally use **Squash and merge**.
Pull requests merged into `dev` should generally use **Squash and merge**.
Reasons:
- keeps `main` history clean and linear
- maps each PR to a single commit on `main`
- reduces release, audit, and rollback complexity
- keeps `dev` history readable and easier to audit during active iteration
- maps each PR to a single integration commit on `dev`
- reduces cherry-pick and conflict cost before creating `release/*`
---
## Maintainer Sync Rules
Because external pull requests are merged directly into `main`, maintainers must sync `main` back to development and release branches to avoid branch drift.
Because external pull requests are merged directly into `dev`, maintainers should treat `dev` as the source branch for daily collaboration and release preparation.
### 1. Sync `main` -> `dev` (required)
The automatic GitHub Actions sync workflow has been removed.
Maintainers should sync `main` back to `dev` manually when needed:
```bash
git checkout dev
git pull
git merge main
git push
```
### 2. Create `release/*` from `dev`
### 1. Create `release/*` from `dev`
Before a release, create a release branch from `dev`, for example:
@@ -100,7 +88,7 @@ git checkout -b release/v0.6.0
git push -u origin release/v0.6.0
```
### 3. Release from `release/*` back to `main`
### 2. Release from `release/*` back to `main`
When release preparation is complete, merge the release branch back into `main` and create a tag:
@@ -113,9 +101,9 @@ git tag v0.6.0
git push origin v0.6.0
```
### 4. Sync `main` back to `dev` after release
### 3. Sync `main` back to `dev` after release
After the release, the same automation still applies. If needed, you can run the workflow manually (`workflow_dispatch`) or execute the fallback commands:
After the release, sync `main` back into `dev` so the next iteration starts from the released code line:
```bash
git checkout dev

View File

@@ -2,14 +2,14 @@
感谢你对本项目的贡献。
本项目采用“发布优先(`main` 为默认分支)+ `release/*` 分支发版”的协作模型。为减少分支漂移与 PR 处理成本,请在提交贡献前先阅读本指南。
本项目当前采用“`dev` 作为默认集成分支,`main` 作为稳定发布分支,`release/*` 负责发版准备”的协作模型。为减少分支漂移与 PR 处理成本,请在提交贡献前先阅读本指南。
---
## 分支模型
- `main`:稳定发布分支,也是仓库默认分支
- `dev`:日常开发集成分支,主要供维护者使用
- `dev`:默认分支,也是日常开发集成分支
- `main`:稳定发布分支
- `release/*`:发布准备分支,主要供维护者使用
- 外部贡献者建议使用以下分支命名:
- `fix/*`:问题修复
@@ -25,21 +25,21 @@ feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
## 外部贡献者如何提 Pull Request
无论是 `fix/*` 还是 `feature/*`**外部贡献者统一直接向 `main` 发起 Pull Request**。
无论是 `fix/*` 还是 `feature/*`**外部贡献者统一直接向 `dev` 发起 Pull Request**。
这样做的原因:
- `main` 是默认分支PR 入口更直观
- 合并后贡献会直接体现在默认分支
- 便于维护者统一做后续同步与发版整理
- `dev` 是当前日常集成分支,评审与合入路径和维护者开发流程一致
- 外部贡献会直接进入触发日常校验和 dev 构建的分支
- 维护者可以直接从 `dev``release/*`,减少额外同步步骤
建议流程:
1. Fork 本仓库
2. 从你自己的仓库创建分支(建议命名为 `fix/*``feature/*`
2. 先同步你 fork 中的 `dev`,再从 `dev` 创建分支(建议命名为 `fix/*``feature/*`
3. 完成代码修改,并进行必要自检
4. 推送到你的远程分支
5. 向本仓库的 `main` 分支发起 Pull Request
5. 向本仓库的 `dev` 分支发起 Pull Request
---
@@ -63,33 +63,21 @@ feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
## PR 合并策略(维护者)
`main` 分支上的 PR 建议使用 **Squash and merge**
`dev` 分支上的 PR 建议使用 **Squash and merge**
原因:
- 保持 `main` 历史干净、线性
- 每个 PR 在 `main` 上对应一个清晰提交
- 降低发布排查与回滚成本
- 保持 `dev` 集成历史清晰、便于审查
- 每个 PR 在 `dev` 上对应一个明确的集成提交
- 降低发版前整理与冲突处理成本
---
## 维护者同步规则
由于外部 PR 会直接合入 `main`,维护者必须及时将 `main` 的变更同步到开发与发布分支,避免分支漂移
由于外部 PR 会直接合入 `dev`,维护者应将 `dev` 作为日常协作与发版准备的主线分支
### 1. main → dev 同步(必做)
仓库已移除 GitHub Actions 自动回灌 workflow。
当前统一采用手动方式将 `main` 同步回 `dev`
```bash
git checkout dev
git pull
git merge main
git push
```
### 2. 发版前从 dev 切 release/*
### 1. 发版前从 dev 切 release/*
发布前由维护者基于 `dev` 创建发布分支,例如:
@@ -100,7 +88,7 @@ git checkout -b release/v0.6.0
git push -u origin release/v0.6.0
```
### 3. release/* → main 发版
### 2. release/* → main 发版
发布准备完成后,将 `release/*` 合并回 `main`,并打标签发布:
@@ -113,9 +101,9 @@ git tag v0.6.0
git push origin v0.6.0
```
### 4. main 回流到 dev发版后必做
### 3. main 回流到 dev发版后必做
发布完成后,仍沿用同一套自动化流程;如有需要,也可以手动触发 `workflow_dispatch`,或执行以下兜底命令,确保开发线与发布线一致
发布完成后,需要将 `main` 回流到 `dev`,确保下一轮开发从已发布代码线继续推进
```bash
git checkout dev

View File

@@ -212,7 +212,7 @@ For the full workflow, branch model, and maintainer sync rules, see:
- [CONTRIBUTING.md](CONTRIBUTING.md)
External contributors should open pull requests directly against `main`.
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">

View File

@@ -195,7 +195,7 @@ sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0
- [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md)
外部贡献者统一直接向 `main` 发起 Pull Request。
外部贡献者应从 `dev` 拉出分支,并统一向 `dev` 发起 Pull Request。
## Star History (Star 增长趋势)

View File

@@ -5,7 +5,8 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
DEFAULT_DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
DEFAULT_DRIVERS=(mariadb oceanbase doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss mongodb tdengine clickhouse)
DEFAULT_PLATFORMS=(darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64)
usage() {
cat <<'EOF'
@@ -14,8 +15,8 @@ usage() {
选项:
--drivers <列表> 指定驱动列表逗号分隔例如kingbase,mongodb
--platform <GOOS/GOARCH>
目标平台,默认使用当前 Go 环境go env GOOS/GOARCH
--platform <目标> 目标平台current、all、GOOS/GOARCH,或逗号分隔列表
默认 current当前 Go 环境
--out-dir <目录> 输出目录根路径默认dist/driver-agents
--bundle-name <文件名> 驱动总包 zip 名称默认GoNavi-DriverAgents.zip
--strict 任一驱动构建失败即中断(默认失败后继续,最后汇总)
@@ -25,6 +26,8 @@ usage() {
./build-driver-agents.sh
./build-driver-agents.sh --drivers kingbase
./build-driver-agents.sh --platform windows/amd64 --drivers kingbase,mongodb
./build-driver-agents.sh --platform all
./build-driver-agents.sh --platform darwin/arm64,windows/amd64,linux/amd64
EOF
}
@@ -33,7 +36,8 @@ normalize_driver() {
name="$(echo "${1:-}" | tr '[:upper:]' '[:lower:]' | xargs)"
case "$name" in
doris|diros) echo "doris" ;;
mariadb|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|mongodb|tdengine|clickhouse)
open_gauss|open-gauss) echo "opengauss" ;;
mariadb|oceanbase|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|mongodb|tdengine|clickhouse)
echo "$name"
;;
*)
@@ -58,6 +62,88 @@ platform_dir_name() {
esac
}
current_platform() {
echo "$(go env GOOS)/$(go env GOARCH)"
}
append_platform() {
local candidate
candidate="$1"
if [[ "$platform_seen" == *"|$candidate|"* ]]; then
return 0
fi
platforms+=("$candidate")
platform_seen="${platform_seen}${candidate}|"
}
normalize_platform() {
local value goos goarch platform_dir
value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
case "$value" in
current|"")
current_platform
;;
*/*)
goos="${value%%/*}"
goarch="${value##*/}"
platform_dir="$(platform_dir_name "$goos")"
if [[ -z "$goos" || -z "$goarch" || "$platform_dir" == "Unknown" ]]; then
return 1
fi
echo "$goos/$goarch"
;;
*)
return 1
;;
esac
}
zip_bundle() {
local bundle_zip_path="$1"
local bundle_stage_dir="$2"
local -a bundle_dirs=()
local dir
for dir in "$bundle_stage_dir"/*; do
[[ -d "$dir" ]] || continue
bundle_dirs+=("$(basename "$dir")")
done
if [[ ${#bundle_dirs[@]} -eq 0 ]]; then
echo "❌ 驱动总包 staging 目录为空。"
exit 1
fi
rm -f "$bundle_zip_path"
if command -v zip >/dev/null 2>&1; then
(
cd "$bundle_stage_dir"
zip -qry "$bundle_zip_path" "${bundle_dirs[@]}"
)
elif command -v python3 >/dev/null 2>&1; then
BUNDLE_STAGE_DIR="$bundle_stage_dir" BUNDLE_ZIP_PATH="$bundle_zip_path" python3 - <<'PY'
import os
import zipfile
from pathlib import Path
stage = Path(os.environ["BUNDLE_STAGE_DIR"])
target = Path(os.environ["BUNDLE_ZIP_PATH"])
with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for path in stage.rglob("*"):
if path.is_file():
zf.write(path, path.relative_to(stage).as_posix())
PY
else
echo "❌ 未找到 zip 或 python3无法生成驱动总包 zip。"
exit 1
fi
}
join_by_comma() {
local IFS=,
echo "$*"
}
driver_csv=""
target_platform=""
out_root="dist/driver-agents"
@@ -103,20 +189,6 @@ if ! command -v go >/dev/null 2>&1; then
exit 1
fi
if [[ -z "$target_platform" ]]; then
target_platform="$(go env GOOS)/$(go env GOARCH)"
fi
if [[ "$target_platform" != */* ]]; then
echo "❌ --platform 参数格式错误,应为 GOOS/GOARCH例如 darwin/arm64"
exit 1
fi
goos="${target_platform%%/*}"
goarch="${target_platform##*/}"
platform_key="${goos}-${goarch}"
platform_dir="$(platform_dir_name "$goos")"
declare -a drivers=()
if [[ -n "$driver_csv" ]]; then
IFS=',' read -r -a raw_drivers <<<"$driver_csv"
@@ -130,67 +202,116 @@ if [[ -n "$driver_csv" ]]; then
else
drivers=("${DEFAULT_DRIVERS[@]}")
fi
revision_driver_csv="$(join_by_comma "${drivers[@]}")"
output_dir="${out_root%/}/${platform_key}"
declare -a platforms=()
platform_seen="|"
if [[ -z "$target_platform" ]]; then
target_platform="current"
fi
IFS=',' read -r -a raw_platforms <<<"$target_platform"
for item in "${raw_platforms[@]}"; do
normalized_platform="$(printf '%s' "$item" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
if [[ "$normalized_platform" == "all" ]]; then
for default_platform in "${DEFAULT_PLATFORMS[@]}"; do
append_platform "$default_platform"
done
continue
fi
normalized_platform="$(normalize_platform "$item")" || {
echo "❌ --platform 参数格式错误,应为 current、all、GOOS/GOARCH 或逗号分隔列表,例如 darwin/arm64,windows/amd64"
exit 1
}
append_platform "$normalized_platform"
done
if [[ ${#platforms[@]} -eq 0 ]]; then
echo "❌ 未指定有效目标平台。"
exit 1
fi
mkdir -p "$out_root"
out_root_abs="$(cd "$out_root" && pwd)"
bundle_stage_dir="$(mktemp -d "${TMPDIR:-/tmp}/gonavi-driver-bundle.XXXXXX")"
bundle_platform_dir="$bundle_stage_dir/$platform_dir"
cleanup() {
rm -rf "$bundle_stage_dir"
}
trap cleanup EXIT
mkdir -p "$output_dir" "$bundle_platform_dir"
output_dir_abs="$(cd "$output_dir" && pwd)"
bundle_zip_path="$output_dir_abs/$bundle_name"
if [[ ${#platforms[@]} -eq 1 ]]; then
single_platform="${platforms[0]}"
single_platform_key="${single_platform/\//-}"
single_output_dir="${out_root%/}/$single_platform_key"
mkdir -p "$single_output_dir"
bundle_zip_path="$(cd "$single_output_dir" && pwd)/$bundle_name"
else
bundle_zip_path="$out_root_abs/$bundle_name"
fi
declare -a built_assets=()
declare -a failed_drivers=()
declare -a skipped_drivers=()
echo "🚀 开始构建 optional-driver-agent"
echo " 平台:$goos/$goarch"
echo " 输出目录:$output_dir_abs"
echo " 平台:${platforms[*]}"
echo " 输出目录:$out_root_abs"
echo " 驱动列表:${drivers[*]}"
for driver in "${drivers[@]}"; do
if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" != "amd64" ]]; then
echo "⚠️ 跳过 duckdb仅支持 windows/amd64"
skipped_drivers+=("$driver")
continue
fi
for platform in "${platforms[@]}"; do
goos="${platform%%/*}"
goarch="${platform##*/}"
platform_key="${goos}-${goarch}"
platform_dir="$(platform_dir_name "$goos")"
output_dir="${out_root%/}/${platform_key}"
bundle_platform_dir="$bundle_stage_dir/$platform_dir"
build_driver="$(build_driver_name "$driver")"
tag="gonavi_${build_driver}_driver"
asset_name="${driver}-driver-agent-${goos}-${goarch}"
if [[ "$goos" == "windows" ]]; then
asset_name="${asset_name}.exe"
fi
output_path="$output_dir_abs/$asset_name"
mkdir -p "$output_dir" "$bundle_platform_dir"
output_dir_abs="$(cd "$output_dir" && pwd)"
cgo_enabled=0
if [[ "$driver" == "duckdb" ]]; then
cgo_enabled=1
fi
echo ""
echo "🧭 生成 driver-agent revision 指纹:$platform"
"$SCRIPT_DIR/tools/generate-driver-agent-revisions.sh" --platform "$platform" --drivers "$revision_driver_csv"
echo "🔧 构建 $driver -> $asset_name (tag=$tag, CGO_ENABLED=$cgo_enabled)"
set +e
CGO_ENABLED="$cgo_enabled" GOOS="$goos" GOARCH="$goarch" GOTOOLCHAIN=auto \
go build -tags "$tag" -trimpath -ldflags "-s -w" -o "$output_path" ./cmd/optional-driver-agent
build_exit=$?
set -e
if [[ $build_exit -ne 0 ]]; then
echo "❌ 构建失败:$driver"
failed_drivers+=("$driver")
if [[ "$strict_mode" == "true" ]]; then
exit $build_exit
for driver in "${drivers[@]}"; do
if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" != "amd64" ]]; then
echo "⚠️ 跳过 duckdb$platform 仅支持 windows/amd64"
skipped_drivers+=("duckdb($platform)")
continue
fi
continue
fi
cp "$output_path" "$bundle_platform_dir/$asset_name"
built_assets+=("$asset_name")
build_driver="$(build_driver_name "$driver")"
tag="gonavi_${build_driver}_driver"
asset_name="${driver}-driver-agent-${goos}-${goarch}"
if [[ "$goos" == "windows" ]]; then
asset_name="${asset_name}.exe"
fi
output_path="$output_dir_abs/$asset_name"
cgo_enabled=0
if [[ "$driver" == "duckdb" ]]; then
cgo_enabled=1
fi
echo "🔧 构建 $driver -> $asset_name (platform=$platform, tag=$tag, CGO_ENABLED=$cgo_enabled)"
set +e
CGO_ENABLED="$cgo_enabled" GOOS="$goos" GOARCH="$goarch" GOTOOLCHAIN=auto \
go build -tags "$tag" -trimpath -ldflags "-s -w" -o "$output_path" ./cmd/optional-driver-agent
build_exit=$?
set -e
if [[ $build_exit -ne 0 ]]; then
echo "❌ 构建失败:$driver ($platform)"
failed_drivers+=("$driver($platform)")
if [[ "$strict_mode" == "true" ]]; then
exit $build_exit
fi
continue
fi
cp "$output_path" "$bundle_platform_dir/$asset_name"
built_assets+=("$platform_dir/$asset_name")
done
done
if [[ ${#built_assets[@]} -eq 0 ]]; then
@@ -198,25 +319,11 @@ if [[ ${#built_assets[@]} -eq 0 ]]; then
exit 1
fi
rm -f "$bundle_zip_path"
if command -v zip >/dev/null 2>&1; then
(
cd "$bundle_stage_dir"
zip -qry "$bundle_zip_path" "$platform_dir"
)
elif command -v ditto >/dev/null 2>&1; then
(
cd "$bundle_stage_dir"
ditto -c -k --sequesterRsrc --keepParent "$platform_dir" "$bundle_zip_path"
)
else
echo "❌ 未找到 zip/ditto无法生成驱动总包 zip。"
exit 1
fi
zip_bundle "$bundle_zip_path" "$bundle_stage_dir"
echo ""
echo "✅ 构建完成"
echo " 单文件输出目录:$output_dir_abs"
echo " 单文件输出目录:$out_root_abs"
echo " 驱动总包:$bundle_zip_path"
echo " 已构建:${built_assets[*]}"
if [[ ${#skipped_drivers[@]} -gt 0 ]]; then

View File

@@ -1,16 +1,42 @@
#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# 配置
APP_NAME="GoNavi"
DIST_DIR="dist"
BUILD_BIN_DIR="build/bin"
DEFAULT_BINARY_NAME="GoNavi" # 对应 wails.json 中的 outputfilename
DEV_VERSION_FILE="version/dev-version.txt"
DEFAULT_DEV_VERSION="0.0.1-test"
# 提取版本号
VERSION=$(grep '"version":' frontend/package.json | head -1 | awk -F: '{ print $2 }' | sed 's/[",]//g' | tr -d '[[:space:]]')
if [ -z "$VERSION" ]; then
VERSION="0.0.0"
fi
resolve_build_version() {
if [ -n "${GONAVI_VERSION:-}" ]; then
printf '%s\n' "${GONAVI_VERSION}"
return
fi
if [ -f "$DEV_VERSION_FILE" ]; then
local dev_version
dev_version=$(head -n 1 "$DEV_VERSION_FILE" | tr -d '\r' | tr -d '[:space:]')
if [ -n "$dev_version" ]; then
printf '%s\n' "$dev_version"
return
fi
fi
local package_version
package_version=$(grep '"version":' frontend/package.json | head -1 | awk -F: '{ print $2 }' | sed 's/[",]//g' | tr -d '[:space:]')
if [ -n "$package_version" ]; then
printf '%s\n' "$package_version"
return
fi
printf '%s\n' "$DEFAULT_DEV_VERSION"
}
VERSION="$(resolve_build_version)"
echo " 检测到版本号: $VERSION"
LDFLAGS="-s -w -X GoNavi-Wails/internal/app.AppVersion=$VERSION"
@@ -20,6 +46,13 @@ RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m'
BUILD_FAILURES=()
record_build_failure() {
local target="$1"
BUILD_FAILURES+=("$target")
}
get_file_size_bytes() {
local target="$1"
if [ ! -f "$target" ]; then
@@ -84,229 +117,102 @@ try_compress_binary_with_upx() {
fi
}
MAC_VOLICON_PATH="build/darwin/icon.icns"
if [ ! -f "$MAC_VOLICON_PATH" ]; then
MAC_VOLICON_PATH=""
fi
clear_macos_bundle_xattrs() {
local bundle_path="$1"
if [ -z "$bundle_path" ] || [ ! -e "$bundle_path" ]; then
return
fi
if command -v xattr >/dev/null 2>&1; then
xattr -cr "$bundle_path" >/dev/null 2>&1 || true
fi
}
package_macos_bundle_zip() {
local app_path="$1"
local archive_path="$2"
local archive_abs
if [ ! -d "$app_path" ]; then
echo -e "${RED} ❌ 未找到 macOS 应用包:$app_path${NC}"
exit 1
fi
archive_abs="$(cd "$(dirname "$archive_path")" && pwd)/$(basename "$archive_path")"
rm -f "$archive_path"
if command -v ditto >/dev/null 2>&1; then
ditto -c -k --sequesterRsrc --keepParent "$app_path" "$archive_abs"
elif command -v zip >/dev/null 2>&1; then
(
cd "$(dirname "$app_path")" && \
zip -qry "$archive_abs" "$(basename "$app_path")"
)
else
echo -e "${RED} ❌ 未找到 ditto/zip无法打包 macOS 应用。${NC}"
exit 1
fi
if [ ! -f "$archive_abs" ]; then
echo -e "${RED} ❌ macOS 应用归档失败:$archive_abs${NC}"
exit 1
fi
}
package_macos_release() {
local platform="$1"
local archive_suffix="$2"
echo -e "${GREEN}🍎 正在构建 macOS (${platform})...${NC}"
generate_driver_agent_revisions "darwin/${platform}"
wails build -platform "darwin/${platform}" -clean -ldflags "$LDFLAGS"
if [ $? -ne 0 ]; then
echo -e "${RED} ❌ macOS ${platform} 构建失败。${NC}"
record_build_failure "macOS ${platform}"
return
fi
local app_src="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
local app_dest_name="${APP_NAME}-${VERSION}-${archive_suffix}.app"
local zip_name="${APP_NAME}-${VERSION}-${archive_suffix}.zip"
mv "$app_src" "$DIST_DIR/$app_dest_name"
local app_bin_path
app_bin_path=$(find "$DIST_DIR/$app_dest_name/Contents/MacOS" -maxdepth 1 -type f -print -quit)
if [ -z "$app_bin_path" ] || [ ! -f "$app_bin_path" ]; then
echo -e "${RED} ❌ 未找到 macOS ${platform} 主程序文件。${NC}"
exit 1
fi
echo -e "${YELLOW} ⚠️ macOS ${platform} 改为无交互 ZIP 打包,不再生成 DMG。${NC}"
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (${platform})..."
clear_macos_bundle_xattrs "$DIST_DIR/$app_dest_name"
codesign --force --deep --sign - "$DIST_DIR/$app_dest_name"
echo " 📦 正在打包 macOS 应用归档 (${platform})..."
package_macos_bundle_zip "$DIST_DIR/$app_dest_name" "$DIST_DIR/$zip_name"
rm -rf "$DIST_DIR/$app_dest_name"
echo " ✅ 已生成 $zip_name"
}
generate_driver_agent_revisions() {
local platform="$1"
echo " 🧭 正在生成 driver-agent revision 指纹 (${platform})..."
./tools/generate-driver-agent-revisions.sh --platform "$platform"
}
echo -e "${GREEN}🚀 开始构建 $APP_NAME $VERSION...${NC}"
# 清理并创建输出目录
rm -rf $DIST_DIR
mkdir -p $DIST_DIR
rm -rf "$DIST_DIR"
mkdir -p "$DIST_DIR"
# --- macOS ARM64 构建 ---
echo -e "${GREEN}🍎 正在构建 macOS (arm64)...${NC}"
wails build -platform darwin/arm64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-arm64.app"
DMG_NAME="${APP_NAME}-${VERSION}-mac-arm64.dmg"
# 移动 .app 到 dist
mv "$APP_SRC" "$DIST_DIR/$APP_DEST_NAME"
APP_BIN_PATH=$(find "$DIST_DIR/$APP_DEST_NAME/Contents/MacOS" -maxdepth 1 -type f -print -quit)
if [ -n "$APP_BIN_PATH" ] && [ -f "$APP_BIN_PATH" ]; then
echo -e "${YELLOW} ⚠️ macOS arm64 不再执行 UPX 压缩,保留原始主程序。${NC}"
else
echo -e "${RED} ❌ 未找到 macOS arm64 主程序文件。${NC}"
exit 1
fi
# Ad-hoc 代码签名(无 Apple Developer 账号时防止 Gatekeeper 报已损坏)
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (arm64)..."
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
# 创建 DMG
if command -v create-dmg &> /dev/null; then
echo " 📦 正在打包 DMG (arm64)..."
# 移除已存在的 DMG (以防万一)
rm -f "$DIST_DIR/$DMG_NAME"
# create-dmg 的 source 需要是“包含 .app 的目录”,不能直接传 .app 路径。
STAGE_DIR=$(mktemp -d "$DIST_DIR/.dmg-stage-${APP_NAME}-${VERSION}-arm64.XXXXXX")
if [ -z "$STAGE_DIR" ] || [ ! -d "$STAGE_DIR" ]; then
echo -e "${RED} ❌ 创建 DMG 临时目录失败,跳过 DMG 打包。${NC}"
else
if command -v ditto &> /dev/null; then
ditto "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
else
cp -R "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
fi
# --sandbox-safe 会跳过 Finder 的 AppleScript 排版,避免打包过程中弹出/打开挂载窗口CI/本地静默打包更友好)。
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO --sandbox-safe)
if [ -n "$MAC_VOLICON_PATH" ]; then
CREATE_DMG_ARGS+=(--volicon "$MAC_VOLICON_PATH")
else
echo -e "${YELLOW} ⚠️ 未找到 macOS 卷图标 (build/darwin/icon.icns),跳过 --volicon。${NC}"
fi
create-dmg "${CREATE_DMG_ARGS[@]}" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "$APP_DEST_NAME" 200 190 \
--hide-extension "$APP_DEST_NAME" \
--app-drop-link 600 185 \
"$DIST_DIR/$DMG_NAME" \
"$STAGE_DIR"
CREATE_DMG_EXIT_CODE=$?
rm -rf "$STAGE_DIR"
if [ $CREATE_DMG_EXIT_CODE -ne 0 ]; then
echo -e "${RED} ❌ create-dmg 执行失败 (exit=$CREATE_DMG_EXIT_CODE),保留 .app 以便排查。${NC}"
else
# create-dmg 可能会在失败时遗留 rw.*.dmg 中间产物;不要直接当作最终产物使用
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
RW_FILE=$(find "$DIST_DIR" -maxdepth 1 -name "rw.*.dmg" -print -quit)
if [ -n "$RW_FILE" ]; then
echo -e "${YELLOW} ⚠️ 检测到 create-dmg 中间产物: $(basename "$RW_FILE"),正在转换为可分发 DMG...${NC}"
hdiutil convert "$RW_FILE" -format UDZO -o "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
rm -f "$RW_FILE"
fi
fi
# 防御性:即使生成了目标文件,也要确保不是 UDRWUDRW 在 Finder 下可能表现为“已损坏/无法打开”)
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
DMG_FORMAT=$(hdiutil imageinfo "$DIST_DIR/$DMG_NAME" 2>/dev/null | awk -F': ' '/^Format:/{print $2; exit}')
if [ "$DMG_FORMAT" = "UDRW" ]; then
echo -e "${YELLOW} ⚠️ 检测到 UDRW可写原始映像正在转换为 UDZO...${NC}"
TMP_UDZO="$DIST_DIR/.tmp.$DMG_NAME"
rm -f "$TMP_UDZO"
hdiutil convert "$DIST_DIR/$DMG_NAME" -format UDZO -o "$TMP_UDZO" >/dev/null 2>&1 && mv "$TMP_UDZO" "$DIST_DIR/$DMG_NAME"
fi
fi
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
if [ $? -ne 0 ]; then
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
else
# 删除中间的 .app 文件,保持目录整洁
rm -rf "$DIST_DIR/$APP_DEST_NAME"
echo " ✅ 已生成 $DMG_NAME"
fi
fi
fi
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
echo -e "${RED} ❌ DMG 生成失败,请检查 create-dmg 输出。${NC}"
fi
fi
else
echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具,跳过 DMG 打包,仅保留 .app。${NC}"
echo " 安装命令: brew install create-dmg"
fi
else
echo -e "${RED} ❌ macOS arm64 构建失败。${NC}"
fi
# --- macOS AMD64 构建 ---
echo -e "${GREEN}🍎 正在构建 macOS (amd64)...${NC}"
wails build -platform darwin/amd64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-amd64.app"
DMG_NAME="${APP_NAME}-${VERSION}-mac-amd64.dmg"
mv "$APP_SRC" "$DIST_DIR/$APP_DEST_NAME"
APP_BIN_PATH=$(find "$DIST_DIR/$APP_DEST_NAME/Contents/MacOS" -maxdepth 1 -type f -print -quit)
if [ -n "$APP_BIN_PATH" ] && [ -f "$APP_BIN_PATH" ]; then
echo -e "${YELLOW} ⚠️ macOS amd64 不再执行 UPX 压缩,保留原始主程序。${NC}"
else
echo -e "${RED} ❌ 未找到 macOS amd64 主程序文件。${NC}"
exit 1
fi
# Ad-hoc 代码签名
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (amd64)..."
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
if command -v create-dmg &> /dev/null; then
echo " 📦 正在打包 DMG (amd64)..."
rm -f "$DIST_DIR/$DMG_NAME"
# create-dmg 的 source 需要是“包含 .app 的目录”,不能直接传 .app 路径。
STAGE_DIR=$(mktemp -d "$DIST_DIR/.dmg-stage-${APP_NAME}-${VERSION}-amd64.XXXXXX")
if [ -z "$STAGE_DIR" ] || [ ! -d "$STAGE_DIR" ]; then
echo -e "${RED} ❌ 创建 DMG 临时目录失败,跳过 DMG 打包。${NC}"
else
if command -v ditto &> /dev/null; then
ditto "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
else
cp -R "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
fi
# --sandbox-safe 会跳过 Finder 的 AppleScript 排版,避免打包过程中弹出/打开挂载窗口CI/本地静默打包更友好)。
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO --sandbox-safe)
if [ -n "$MAC_VOLICON_PATH" ]; then
CREATE_DMG_ARGS+=(--volicon "$MAC_VOLICON_PATH")
else
echo -e "${YELLOW} ⚠️ 未找到 macOS 卷图标 (build/darwin/icon.icns),跳过 --volicon。${NC}"
fi
create-dmg "${CREATE_DMG_ARGS[@]}" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "$APP_DEST_NAME" 200 190 \
--hide-extension "$APP_DEST_NAME" \
--app-drop-link 600 185 \
"$DIST_DIR/$DMG_NAME" \
"$STAGE_DIR"
CREATE_DMG_EXIT_CODE=$?
rm -rf "$STAGE_DIR"
if [ $CREATE_DMG_EXIT_CODE -ne 0 ]; then
echo -e "${RED} ❌ create-dmg 执行失败 (exit=$CREATE_DMG_EXIT_CODE),保留 .app 以便排查。${NC}"
else
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
RW_FILE=$(find "$DIST_DIR" -maxdepth 1 -name "rw.*.dmg" -print -quit)
if [ -n "$RW_FILE" ]; then
echo -e "${YELLOW} ⚠️ 检测到 create-dmg 中间产物: $(basename "$RW_FILE"),正在转换为可分发 DMG...${NC}"
hdiutil convert "$RW_FILE" -format UDZO -o "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
rm -f "$RW_FILE"
fi
fi
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
DMG_FORMAT=$(hdiutil imageinfo "$DIST_DIR/$DMG_NAME" 2>/dev/null | awk -F': ' '/^Format:/{print $2; exit}')
if [ "$DMG_FORMAT" = "UDRW" ]; then
echo -e "${YELLOW} ⚠️ 检测到 UDRW可写原始映像正在转换为 UDZO...${NC}"
TMP_UDZO="$DIST_DIR/.tmp.$DMG_NAME"
rm -f "$TMP_UDZO"
hdiutil convert "$DIST_DIR/$DMG_NAME" -format UDZO -o "$TMP_UDZO" >/dev/null 2>&1 && mv "$TMP_UDZO" "$DIST_DIR/$DMG_NAME"
fi
fi
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
if [ $? -ne 0 ]; then
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
else
rm -rf "$DIST_DIR/$APP_DEST_NAME"
echo " ✅ 已生成 $DMG_NAME"
fi
fi
fi
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
echo -e "${RED} ❌ DMG 生成失败。${NC}"
fi
fi
else
echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具。${NC}"
fi
else
echo -e "${RED} ❌ macOS amd64 构建失败。${NC}"
fi
package_macos_release "arm64" "mac-arm64"
package_macos_release "amd64" "mac-amd64"
# --- Windows AMD64 构建 ---
echo -e "${GREEN}🪟 正在构建 Windows (amd64)...${NC}"
if command -v x86_64-w64-mingw32-gcc &> /dev/null; then
generate_driver_agent_revisions "windows/amd64"
wails build -platform windows/amd64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_EXE="$DIST_DIR/${APP_NAME}-${VERSION}-windows-amd64.exe"
@@ -315,6 +221,7 @@ if command -v x86_64-w64-mingw32-gcc &> /dev/null; then
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-amd64.exe"
else
echo -e "${RED} ❌ Windows amd64 构建失败。${NC}"
record_build_failure "Windows amd64"
fi
else
echo -e "${YELLOW} ⚠️ 未找到 MinGW 工具 (x86_64-w64-mingw32-gcc),跳过 Windows amd64 构建。${NC}"
@@ -323,6 +230,7 @@ fi
# --- Windows ARM64 构建 ---
echo -e "${GREEN}🪟 正在构建 Windows (arm64)...${NC}"
if command -v aarch64-w64-mingw32-gcc &> /dev/null; then
generate_driver_agent_revisions "windows/arm64"
wails build -platform windows/arm64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_EXE="$DIST_DIR/${APP_NAME}-${VERSION}-windows-arm64.exe"
@@ -331,6 +239,7 @@ if command -v aarch64-w64-mingw32-gcc &> /dev/null; then
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-arm64.exe"
else
echo -e "${RED} ❌ Windows arm64 构建失败。${NC}"
record_build_failure "Windows arm64"
fi
else
echo -e "${YELLOW} ⚠️ 未找到 MinGW ARM64 工具 (aarch64-w64-mingw32-gcc),跳过 Windows arm64 构建。${NC}"
@@ -345,6 +254,7 @@ CURRENT_ARCH=$(uname -m)
if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "x86_64" ]; then
# 本机 Linux amd64直接构建
generate_driver_agent_revisions "linux/amd64"
wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
@@ -359,12 +269,14 @@ if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "x86_64" ]; then
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-amd64.tar.gz"
else
echo -e "${RED} ❌ Linux amd64 构建失败。${NC}"
record_build_failure "Linux amd64"
fi
elif command -v x86_64-linux-gnu-gcc &> /dev/null; then
# macOS 或其他系统,尝试交叉编译
export CC=x86_64-linux-gnu-gcc
export CXX=x86_64-linux-gnu-g++
export CGO_ENABLED=1
generate_driver_agent_revisions "linux/amd64"
wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
@@ -378,6 +290,7 @@ elif command -v x86_64-linux-gnu-gcc &> /dev/null; then
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-amd64.tar.gz"
else
echo -e "${RED} ❌ Linux amd64 交叉编译失败。${NC}"
record_build_failure "Linux amd64"
fi
unset CC CXX CGO_ENABLED
else
@@ -389,6 +302,7 @@ fi
echo -e "${GREEN}🐧 正在构建 Linux (arm64)...${NC}"
if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "aarch64" ]; then
# 本机 Linux arm64直接构建
generate_driver_agent_revisions "linux/arm64"
wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
@@ -402,12 +316,14 @@ if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "aarch64" ]; then
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-arm64.tar.gz"
else
echo -e "${RED} ❌ Linux arm64 构建失败。${NC}"
record_build_failure "Linux arm64"
fi
elif command -v aarch64-linux-gnu-gcc &> /dev/null; then
# 交叉编译
export CC=aarch64-linux-gnu-gcc
export CXX=aarch64-linux-gnu-g++
export CGO_ENABLED=1
generate_driver_agent_revisions "linux/arm64"
wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
@@ -421,6 +337,7 @@ elif command -v aarch64-linux-gnu-gcc &> /dev/null; then
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-arm64.tar.gz"
else
echo -e "${RED} ❌ Linux arm64 交叉编译失败。${NC}"
record_build_failure "Linux arm64"
fi
unset CC CXX CGO_ENABLED
else
@@ -454,12 +371,21 @@ else
fi
echo ""
echo -e "${GREEN}🎉 所有任务完成!构建产物在 'dist/' 目录下:${NC}"
if [ "${#BUILD_FAILURES[@]}" -gt 0 ]; then
echo -e "${RED}❌ 构建未完全成功,失败平台:${BUILD_FAILURES[*]}${NC}"
echo -e "${YELLOW}📦 已成功生成的产物在 'dist/' 目录下:${NC}"
else
echo -e "${GREEN}🎉 所有任务完成!构建产物在 'dist/' 目录下:${NC}"
fi
ls -lh "$DIST_DIR"
echo ""
echo -e "${GREEN}📋 支持的平台:${NC}"
echo " • macOS (Intel/Apple Silicon): .dmg"
echo " • macOS (Intel/Apple Silicon): .zip"
echo " • Windows (x64/ARM64): .exe"
echo " • Linux (x64/ARM64): .tar.gz"
echo ""
echo -e "${YELLOW}💡 提示Linux AppImage 包请使用 GitHub Actions CI/CD 构建。${NC}"
if [ "${#BUILD_FAILURES[@]}" -gt 0 ]; then
exit 1
fi

339
cmd/manualtestseed/main.go Normal file
View File

@@ -0,0 +1,339 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"GoNavi-Wails/internal/ai"
aiservice "GoNavi-Wails/internal/ai/service"
"GoNavi-Wails/internal/app"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/secretstore"
)
const (
modeSeedSecureStorage = "seed-secure-storage"
modeSeedAIUpdate = "seed-ai-update"
)
const (
testConnectionID = "manualtest-postgres"
testSecureProviderID = "manualtest-secure-provider"
testPendingProviderID = "manualtest-pending-provider"
testBackupDirName = "manual-test-backups"
connectionsFileName = "connections.json"
globalProxyFileName = "global_proxy.json"
aiConfigFileName = "ai_config.json"
securityUpdateFileName = "config-security-update.json"
)
type backupManifest struct {
CreatedAt string `json:"createdAt"`
ConfigDir string `json:"configDir"`
Files []backupManifestFile `json:"files"`
}
type backupManifestFile struct {
RelativePath string `json:"relativePath"`
Existed bool `json:"existed"`
}
type storedAIConfig struct {
SchemaVersion int `json:"schemaVersion,omitempty"`
Providers []ai.ProviderConfig `json:"providers"`
ActiveProvider string `json:"activeProvider"`
SafetyLevel string `json:"safetyLevel"`
ContextLevel string `json:"contextLevel"`
}
func main() {
mode := flag.String("mode", modeSeedSecureStorage, "seed mode: seed-secure-storage | seed-ai-update")
flag.Parse()
configDir, err := resolveConfigDir()
if err != nil {
fatalf("resolve config dir failed: %v", err)
}
store := secretstore.NewKeyringStore()
if err := store.HealthCheck(); err != nil {
fatalf("secret store unavailable: %v", err)
}
backupDir, err := backupConfigFiles(configDir)
if err != nil {
fatalf("backup config files failed: %v", err)
}
switch strings.TrimSpace(*mode) {
case modeSeedSecureStorage:
if err := seedSecureStorage(configDir, store); err != nil {
fatalf("seed secure storage failed: %v", err)
}
fmt.Printf("mode=%s\nbackup=%s\nconnectionId=%s\nproviderId=%s\n", modeSeedSecureStorage, backupDir, testConnectionID, testSecureProviderID)
case modeSeedAIUpdate:
if err := seedAIUpdate(configDir, store); err != nil {
fatalf("seed ai update failed: %v", err)
}
fmt.Printf("mode=%s\nbackup=%s\npendingProviderId=%s\n", modeSeedAIUpdate, backupDir, testPendingProviderID)
default:
fatalf("unsupported mode: %s", *mode)
}
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}
func resolveConfigDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(homeDir, ".gonavi"), nil
}
func backupConfigFiles(configDir string) (string, error) {
backupDir := filepath.Join(configDir, testBackupDirName, time.Now().Format("20060102-150405"))
files := []string{
connectionsFileName,
globalProxyFileName,
aiConfigFileName,
filepath.Join("migrations", securityUpdateFileName),
}
manifest := backupManifest{
CreatedAt: time.Now().Format(time.RFC3339),
ConfigDir: configDir,
Files: make([]backupManifestFile, 0, len(files)),
}
for _, relativePath := range files {
srcPath := filepath.Join(configDir, relativePath)
info, err := os.Stat(srcPath)
if err != nil {
if os.IsNotExist(err) {
manifest.Files = append(manifest.Files, backupManifestFile{
RelativePath: relativePath,
Existed: false,
})
continue
}
return "", err
}
if info.IsDir() {
continue
}
dstPath := filepath.Join(backupDir, relativePath)
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return "", err
}
data, err := os.ReadFile(srcPath)
if err != nil {
return "", err
}
if err := os.WriteFile(dstPath, data, 0o644); err != nil {
return "", err
}
manifest.Files = append(manifest.Files, backupManifestFile{
RelativePath: relativePath,
Existed: true,
})
}
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return "", err
}
manifestData, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return "", err
}
if err := os.WriteFile(filepath.Join(backupDir, "manifest.json"), manifestData, 0o644); err != nil {
return "", err
}
return backupDir, nil
}
func seedSecureStorage(configDir string, store secretstore.SecretStore) error {
if err := cleanupKnownTestSecrets(store); err != nil {
return err
}
appService := app.NewAppWithSecretStore(store)
_ = appService.DeleteConnection(testConnectionID)
if _, err := appService.SaveConnection(connection.SavedConnectionInput{
ID: testConnectionID,
Name: "手工测试 PostgreSQL",
Config: connection.ConnectionConfig{
ID: testConnectionID,
Type: "postgres",
Host: "127.0.0.1",
Port: 5432,
User: "postgres",
Password: "manualtest-pg-secret",
Database: "postgres",
},
}); err != nil {
return err
}
if _, err := appService.SaveGlobalProxy(connection.SaveGlobalProxyInput{
Enabled: true,
Type: "http",
Host: "127.0.0.1",
Port: 7890,
User: "manual-test",
Password: "manualtest-proxy-secret",
}); err != nil {
return err
}
storeConfig := aiservice.NewProviderConfigStore(configDir, store)
snapshot, err := storeConfig.LoadRuntime()
if err != nil {
return err
}
snapshot.Providers = filterProviders(snapshot.Providers, testSecureProviderID, testPendingProviderID)
snapshot.Providers = append(snapshot.Providers, ai.ProviderConfig{
ID: testSecureProviderID,
Type: "custom",
Name: "手工测试 Secure Provider",
APIKey: "manualtest-ai-secret",
BaseURL: "https://api.openai.com/v1",
Model: "gpt-4o-mini",
APIFormat: "openai",
Headers: map[string]string{
"Authorization": "Bearer manualtest-header-secret",
"X-Trace-Id": "manualtest-visible",
},
MaxTokens: 2048,
Temperature: 0.2,
})
if snapshot.SafetyLevel == "" {
snapshot.SafetyLevel = ai.PermissionReadOnly
}
if snapshot.ContextLevel == "" {
snapshot.ContextLevel = ai.ContextSchemaOnly
}
return storeConfig.Save(snapshot)
}
func seedAIUpdate(configDir string, store secretstore.SecretStore) error {
if err := cleanupKnownTestSecrets(store); err != nil {
return err
}
configPath := filepath.Join(configDir, aiConfigFileName)
cfg, err := readStoredAIConfig(configPath)
if err != nil {
return err
}
cfg.Providers = filterProviders(cfg.Providers, testSecureProviderID, testPendingProviderID)
cfg.Providers = append(cfg.Providers, ai.ProviderConfig{
ID: testPendingProviderID,
Type: "custom",
Name: "手工测试 待迁移 AI",
APIKey: "manualtest-ai-update-secret",
BaseURL: "https://api.openai.com/v1",
Model: "gpt-4o-mini",
APIFormat: "openai",
MaxTokens: 1024,
})
if cfg.SchemaVersion == 0 {
cfg.SchemaVersion = 2
}
if cfg.Providers == nil {
cfg.Providers = []ai.ProviderConfig{}
}
if err := os.MkdirAll(configDir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(configPath, data, 0o644)
}
func readStoredAIConfig(configPath string) (storedAIConfig, error) {
cfg := storedAIConfig{
Providers: []ai.ProviderConfig{},
SafetyLevel: string(ai.PermissionReadOnly),
ContextLevel: string(ai.ContextSchemaOnly),
SchemaVersion: 2,
ActiveProvider: "",
}
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return storedAIConfig{}, err
}
if err := json.Unmarshal(data, &cfg); err != nil {
return storedAIConfig{}, err
}
if cfg.Providers == nil {
cfg.Providers = []ai.ProviderConfig{}
}
return cfg, nil
}
func filterProviders(providers []ai.ProviderConfig, excludedIDs ...string) []ai.ProviderConfig {
excluded := make(map[string]struct{}, len(excludedIDs))
for _, id := range excludedIDs {
excluded[strings.TrimSpace(id)] = struct{}{}
}
filtered := make([]ai.ProviderConfig, 0, len(providers))
for _, provider := range providers {
if _, skip := excluded[strings.TrimSpace(provider.ID)]; skip {
continue
}
filtered = append(filtered, provider)
}
return filtered
}
func cleanupKnownTestSecrets(store secretstore.SecretStore) error {
type secretRef struct {
kind string
id string
}
refs := []secretRef{
{kind: "connection", id: testConnectionID},
{kind: "global-proxy", id: "default"},
{kind: "ai-provider", id: testSecureProviderID},
{kind: "ai-provider", id: testPendingProviderID},
}
for _, item := range refs {
ref, err := secretstore.BuildRef(item.kind, item.id)
if err != nil {
return err
}
if err := store.Delete(ref); err != nil && !isIgnorableDeleteError(err) {
return err
}
}
return nil
}
func isIgnorableDeleteError(err error) bool {
if err == nil || os.IsNotExist(err) {
return true
}
message := strings.ToLower(strings.TrimSpace(err.Error()))
return strings.Contains(message, "could not be found") ||
strings.Contains(message, "not be found in the keyring") ||
strings.Contains(message, "element not found")
}

View File

@@ -37,6 +37,7 @@ type agentResponse struct {
const (
agentMethodConnect = "connect"
agentMethodClose = "close"
agentMethodMetadata = "metadata"
agentMethodPing = "ping"
agentMethodQuery = "query"
agentMethodExec = "exec"
@@ -131,6 +132,13 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse {
*inst = nil
}
return resp
case agentMethodMetadata:
resp.Data = map[string]string{
"driverType": strings.TrimSpace(agentDriverType),
"agentRevision": db.OptionalDriverAgentRevision(agentDriverType),
"protocolSchema": "json-lines-v1",
}
return resp
}
if *inst == nil {

View File

@@ -10,6 +10,7 @@ import (
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
)
type duckMapLike map[any]any
@@ -66,6 +67,33 @@ func TestNormalizeAgentResponseData_KeepByteSlice(t *testing.T) {
}
}
func TestHandleRequestMetadataReportsAgentRevision(t *testing.T) {
previousDriverType := agentDriverType
previousFactory := agentDatabaseFactory
t.Cleanup(func() {
agentDriverType = previousDriverType
agentDatabaseFactory = previousFactory
})
agentDriverType = "clickhouse"
agentDatabaseFactory = func() db.Database { return nil }
var inst db.Database
resp := handleRequest(&inst, agentRequest{ID: 7, Method: agentMethodMetadata})
if !resp.Success {
t.Fatalf("metadata request failed: %s", resp.Error)
}
data, ok := resp.Data.(map[string]string)
if !ok {
t.Fatalf("metadata response data type = %T", resp.Data)
}
if data["driverType"] != "clickhouse" {
t.Fatalf("unexpected driver type: %q", data["driverType"])
}
if data["agentRevision"] != db.OptionalDriverAgentRevision("clickhouse") {
t.Fatalf("unexpected agent revision: %q", data["agentRevision"])
}
}
type fakeAgentTimeoutDB struct {
queryCalled bool
queryContextCalled bool

View File

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

View File

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

View File

@@ -7,6 +7,12 @@
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/mariadb"
},
"oceanbase": {
"engine": "go",
"version": "1.9.3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/oceanbase"
},
"doris": {
"engine": "go",
"version": "1.9.3",
@@ -61,6 +67,12 @@
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/vastbase"
},
"opengauss": {
"engine": "go",
"version": "1.11.1",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/opengauss"
},
"mongodb": {
"engine": "go",
"version": "2.5.0",

View File

@@ -1,111 +0,0 @@
# 2026-04-11 Issue Backlog Tracking
## Scope
- 分支:`codex/issue-242-data-root`
- 策略:按 GitHub issue 创建时间从早到晚逐条处理
- 提交要求:每条 issue 单独本地提交,提交信息使用 `Fixes #<issue>`
## Progress
| Issue | Title | Status | Commit |
| --- | --- | --- | --- |
| #242 | 希望有自定义数据存储位置功能 | Fixed | `1f617f9` |
| #287 | 建议补充 Sql Server 数据库图标 | Fixed | `60b63d7` |
| #305 | 金仓数据库设计表新增字段保存失败 | Fixed | `f696f52` |
| #306 | 驱动下载 | Fixed | `8297829` |
| #308 | clickhouse 获取数据库列表失败 | Fixed | `5d86ee7` |
| #310 | 选择库后,右侧行显示各个表 | Fixed | `808c773` |
| #311 | WIN 系统的执行 500 多条 insert 语句要几分钟 | Fixed | `83fe3d4` |
| #315 | 窗体内缩放异常 | Fixed | `5038ae5` |
| #316 | 人大金仓数据库驱动版本过低 | Fixed | `aa1bb5b` |
| #317 | 驱动管理增加导入 jar 功能 | Blocked | - |
| #318 | mysql,bit 列,修改成 1 失败 | Fixed | `89d79ff` |
| #319 | 关于运行外部 sql 文件的一些建议 | Deferred | - |
| #320 | 无法连接达梦数据库 | Fixed | `1c2377b` |
| #322 | 【拖选复制】希望添加 查询结果表格可以拖选复制效果就如操作excel表格的选择复制一样 | Fixed | Pending |
| #325 | 有没有考虑对数据库的驱动版本进行选择或者自定义? | Fixed | `af5e842` |
| #327 | SHOW DATABASES 报错 | Fixed | `fb500ee` |
| #328 | [Bug] 安装更新失败 | Fixed | `426ef3b` |
| #329 | 如果调整了左侧导航栏的宽度后,建议左侧导航栏内增加横向滚动查看 | Fixed | `fcade0f` |
| #330 | 建议在查询结果表格中增加自适应内容列宽的功能 | Fixed | `632e57e` |
| #331 | 重复连接 DB一分钟重试了 60 多次 | Fixed | `ca76440` |
| #351 | 为什么没有截断和清空表的功能呀? | Fixed | Pending |
## Notes
### #317
- 当前驱动管理只支持内置 Go 驱动和可选 Go 驱动代理包。
- 仓库内不存在 JDBC/JAR 装载、Java 运行时探测、classpath 管理或桥接执行链路。
- 在现有架构下直接增加 “导入 jar” 入口会形成假功能,因此暂记为架构阻塞,不做伪实现。
### #318
- 根因MySQL 写入归一化只覆盖时间列,`bit` 列提交时会把前端传来的 `"1"`/`"0"` 原样透传给驱动。
- 处理:为 MySQL `bit` 列补充写入值归一化,将常见文本/布尔/数值输入转换为驱动可接受的 `[]byte`
- 验证:补充 `internal/db/mysql_value_test.go` 回归测试,覆盖 `bit(1)` 的 insert/update 写入路径。
### #319
- 现有应用已支持“运行外部 SQL 文件”,但 issue 诉求包含目录树、目录加载、双击文件打开等整组工作区能力。
- 该项已超出单点缺陷修复范围,暂按功能增强项顺延,避免在逐条修 bug 流程中引入大范围 UI/状态管理重构。
### #320
- 达梦当前走可选 Go 驱动代理安装链路,不支持 JAR 导入属于既有架构边界。
- 根因:驱动 release 资产缓存把 `GoNavi-DriverAgents.zip` 里的 bundle 条目也混进了“顶层已发布 asset”集合导致安装链路误以为存在单独的 `dameng-driver-agent-*.exe` 下载地址。
- 处理:缓存层区分真实 release 顶层 asset 与 bundle index 条目,安装 URL 解析仅在真实顶层 asset 存在时才走直链bundle-only 驱动改为直接进入总包提取回退,不再先卡在 20% 试无效 URL。
- 验证:补充 `internal/app/methods_driver_version_test.go` 回归测试,覆盖 bundle-only 达梦驱动跳过伪直链,并回归 Mongo 历史版本与本地导入链路。
### #327
- 根因:低权限 MySQL 账号执行 `SHOW DATABASES` 会直接报错,当前实现没有回退路径。
- 处理:为数据库列表查询增加 `SELECT DATABASE()` 回退,仅保留当前连接库时也能正常展示。
- 验证:补充 `internal/db/mysql_metadata_test.go` 回归测试,覆盖有权限、多库和低权限回退场景。
### #328
- 根因Windows 更新脚本在批处理执行、错误码读取和重启命令上不够稳,`cmd /C start`、LF 行尾和块内 `%ERRORLEVEL%` 在实际环境下容易引发安装失败。
- 处理:更新脚本统一输出为 CRLF块内错误码改为延迟展开旧文件回退路径统一为 `TARGET_OLD`,并将脚本启动方式收敛为 `cmd.exe /D /C call <script>`
- 验证:补充 `internal/app/methods_update_windows_script_test.go`覆盖批处理语法、Win10 回退路径、CRLF 行尾、延迟展开和启动命令构造。
### #325
- 根因TDengine 的版本列表虽然支持下拉选择,但后端在抓取与缓存 Go 模块版本时只保留最近 5 个版本,导致 `3.5.x / 3.3.x / 3.0.x` 这类旧版根本不会进入选择列表。
- 处理:放宽 TDengine 的历史版本窗口,并补充离线 fallback 版本矩阵;同时扩大模块版本缓存上限,确保旧版不会在抓取阶段就被截断。
- 验证:补充 `internal/app/methods_driver_version_test.go` 回归测试,覆盖缓存命中与 fallback 两条路径,并回归 Mongo 版本约束逻辑。
### #329
- 根因:侧边栏连接树被全局 Tree 样式固定为 `width: 100%`,标题同时启用了省略截断,导致缩窄侧栏后长节点无法形成横向溢出。
- 处理:为 Sidebar 树增加专用横向滚动容器,并在 Sidebar 作用域内覆写 Tree 宽度与标题截断规则,让节点宽度随内容扩展且保留最小占满。
- 验证:执行 `frontend``npm run build`,确认 TS/CSS 改动编译通过且仅作用于 Sidebar 树。
### #331
- 根因:连接失败时存在双层重试叠加。`DBGetDatabases / DBGetTables / DBQuery` 在缓存失效后本来就会主动重建连接一次,而 `connectDatabaseWithStartupRetry` 在稳定期仍会额外放行一次瞬时错误自动重试,导致一次后台探测会被放大成多次真实建连。
- 处理:将连接自动重试范围收敛到应用启动保护窗口内;稳定期下所有连接探测与重建都只执行一次,避免后台挂起场景持续放大失败流量。
- 验证:补充并更新 `internal/app/app_startup_connect_retry_test.go`,覆盖稳定期瞬时失败不重试、不再输出重试提示,以及启动期仍保留完整重试预算。
### #330
- 根因:查询结果表格已经支持拖拽调整列宽,但 resize handle 没有提供双击自适应逻辑,导致用户只能靠手工拖拽慢慢试宽度。
- 处理:为 `DataGrid` 的列宽拖拽手柄增加双击入口,按当前表头与已加载结果集内容估算目标宽度,并直接复用现有 `columnWidths` 状态更新布局。
- 验证:新增 `frontend/src/components/dataGridAutoWidth.test.ts` 覆盖列宽估算规则,并执行 `frontend``npm run build` 确认 TS 与打包通过。
### #322
- 根因:`DataGrid` 已经具备拖选单元格和选区状态维护能力,但当前复制能力只支持把同一行选中的列值暂存为内部 patch用于“粘贴到选中行”没有把矩形选区真正导出到系统剪贴板。
- 处理:新增选区复制 helper将矩形选区按当前可见行列顺序导出为制表符文本同时补上工具栏“复制选区”按钮和 `Ctrl/Cmd+C` 快捷键,让拖选后的复制行为更接近 Excel。
- 验证:新增 `frontend/src/components/dataGridSelectionCopy.test.ts` 覆盖选区排序与剪贴板文本规整规则,并执行 `frontend``npm run build` 确认功能接线通过。
### #351
- 根因:后端已有批量清空表能力,但前端单表危险操作菜单只暴露了“删除表”,没有把“截断表 / 清空表”作为显式入口提供给用户;同时批量“清空”动作底层语义也混用了 `TRUNCATE/DELETE`
- 处理:后端将“截断表”和“清空表”拆分为显式能力,统一通过 helper 生成多数据库 SQL前端为 Sidebar 和 TableOverview 的表菜单补上两个危险操作入口,并仅在明确支持 `TRUNCATE TABLE` 的数据库类型上显示“截断表”。
- 验证:新增 `internal/app/methods_file_clear_test.go``frontend/src/components/tableDataDangerActions.test.ts`,并执行 `go test ./...``frontend``npm run build` 确认全量通过。
## Next
- 继续处理下一个最早且可直接落地的开放 issue。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,483 @@
# JVM 缓存可视化编辑设计
## 1. 背景
当前用户在公司 Java 项目中经常把缓存或运行时状态直接保存在 JVM 内存中。出现数据脏值、缓存穿透、临时纠偏或排障时,通常只有两种方式:
- 为特定业务临时补管理接口
- 重启应用并依赖重新初始化
这两种方式都存在明显问题:
- 临时接口会污染业务代码,并带来后续维护和权限风险
- 重启应用成本高,且不适合用于精确修复单个缓存项
GoNavi 现有已具备三类可复用基础:
- 统一连接与工作台能力:`frontend/src/components/ConnectionModal.tsx``frontend/src/components/Sidebar.tsx``frontend/src/components/TabManager.tsx`
- 独立运行时能力样板Redis 通过 `internal/app/methods_redis.go` 和专用前端视图实现,不依赖 SQL `Database` 抽象
- AI 与日志能力底座:`frontend/src/components/AIChatPanel.tsx``frontend/src/components/QueryEditor.tsx``frontend/src/components/LogPanel.tsx`
因此GoNavi 有条件扩展出 JVM 运行时连接与受控编辑能力,但不能简单把该需求理解为“新数据库驱动”。
## 2. 目标
- 为 GoNavi 增加统一的 `JVM Connector` 子系统,用于连接和浏览 Java 服务的运行时缓存/管理对象
- 在同一套 UI 下支持多种接入模式,并根据目标 JVM 能力自动协商或手动切换
- 提供结构化的缓存浏览、值检查、受控修改、操作预览和审计记录
- 允许 AI 参与解释、分析和生成修改计划,但不默认开放 AI 自动执行
- 尽量避免强依赖 `-javaagent` 或运行时动态 attach适配企业内对生产进程注入普遍敏感的环境
## 3. 非目标
- 不承诺“任意 JVM 内任意对象均可直接读写”
- 不在首期支持任意 Java 表达式执行、任意反射路径写值或任意 classloader 深度探测
- 不把 JVM 功能强行塞进现有 SQL `Database` / driver-agent 抽象
- 不承诺通过 Agent 模式支持所有缓存框架或任意深层对象写入
- 不绕过目标服务现有认证、鉴权和网络边界
## 4. 需求与约束
### 4.1 需求清单
- 统一配置 JVM 连接
- 探测当前 JVM 支持的接入模式与可用能力
- 浏览缓存空间、管理对象和受控操作
- 查看值快照与元数据
- 执行受控修改,并提供 before/after 预览
- 将操作结果写入审计记录
- 支持 AI 对资源结构和修改方案进行分析
### 4.2 已确认约束
- 用户倾向通用型产品形态,但目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach
- 企业环境下,稳定性与安全性优先级高于“黑科技式通用能力”
- 一期应优先基于标准协议和业务可控接入面,而不是侵入式 runtime 操作
## 5. 现状分析
### 5.1 GoNavi 架构启示
- `internal/db/database.go` 面向标准化数据源 CRUD适合 SQL 类资源
- `internal/app/methods_redis.go` 证明 GoNavi 已支持“独立运行时系统能力线”
- `frontend/src/components/RedisViewer.tsx``frontend/src/components/RedisCommandEditor.tsx` 提供了树形浏览、结构化值编辑和控制台交互样板
- `frontend/src/components/AIChatPanel.tsx``frontend/src/components/ai/AIMessageBubble.tsx` 已具备 AI 交互和危险执行确认能力
### 5.2 结论
JVM 缓存可视化编辑应当比照 Redis 独立建模,新增 `JVM Connector` 子系统,而不是复用 SQL `Database` 接口。
## 6. 方案比较
### 方案 A单一路径通用 Agent
- 描述:统一要求目标 JVM 通过 `-javaagent` 或运行时 attach 暴露运行时对象访问能力
- 优点:
- 理论能力上限最高
- 可覆盖更多自研缓存和深层对象
- 缺点:
- 与已知企业约束直接冲突
- 风险最高,部署与安全成本高
- 与首期产品化目标不匹配
### 方案 B多接入模式 + 能力协商
- 描述:统一做 `JVM Connector`,底层同时支持 `JMX``Management Endpoint``Agent`
- 优点:
- 产品形态统一
- 能根据目标 JVM 能力降级
- 可先做低风险路径,后续再扩展高级模式
- 缺点:
- 不同模式能力不一致UI 与权限模型更复杂
### 方案 C只做业务侧管理端点
- 描述:完全放弃通用接入,只提供官方 Starter/管理端点接入
- 优点:
- 结构最稳AI 最容易接入
- 权限、审计、预览、回滚最好做
- 缺点:
- 不满足“尽量通用”的产品定位
- 无法覆盖仅开放 JMX 的存量系统
## 7. 选型
采用方案 B。当前已落地
- `JMX Provider`
- `Management Endpoint Provider`
- `Agent Provider`(高级可选模式,要求目标 Java 服务显式预埋 GoNavi Java Agent
## 8. 目标架构
### 8.1 总体结构
新增统一的 `JVM Connector` 子系统,分为五层:
- `Connection Layer`
- 新增 `jvm` 连接类型
- 保存目标地址、认证、允许模式、首选模式、环境标签等配置
- `Capability Layer`
- 建立连接后探测当前支持的 provider 与能力矩阵
- `Provider Layer`
- `JMX Provider`
- `Management Endpoint Provider`
- `Agent Provider`(预留)
- `Resource Layer`
- 将不同来源统一映射为结构化资源
- `Guard Layer`
- 统一负责预览、确认、审计、回读验证、错误归一化
### 8.2 设计原则
- UI 统一,协议多态
- 读写分离,修改必须经过 Guard Layer
- provider 不得自行绕过权限与审计链路
- 能力不足时显式降级,不提供“看似可用、实际不可执行”的假入口
## 9. Provider 设计
### 9.1 JMX Provider
- 负责:
- 建立 JMX/RMI 连接
- 发现 MBean
- 读取属性
- 调用白名单操作
- 写入允许修改的白名单属性
- 适用场景:
- 目标 JVM 已开放 JMX
- 缓存或管理对象已暴露为 MBean
- 特点:
- 低侵入、标准化、可落地
- key/value 级资源能力通常有限
### 9.2 Management Endpoint Provider
- 负责:
- 调用业务服务暴露的 GoNavi 管理端点或 Starter
- 返回结构化缓存资源、元数据和受控动作
- 提供修改预览与回滚信息
- 适用场景:
- 业务方愿意接入轻量 Starter/管理端点
- 需要更强的 key/value 级浏览与修改能力
- 特点:
- 最适合产品化和 AI 协同
- 权限、脱敏、审计、回滚最容易做
### 9.3 Agent Provider
- 负责:
- 在特定环境下通过 GoNavi Java Agent 暴露受控管理端口
- 提供比 JMX 更贴近缓存资源模型的结构化浏览、预览与写入能力
- 定位:
- 高级模式
- 不默认启用
- 需要目标 Java 服务以 `-javaagent` 方式显式启动
## 10. 统一资源模型
建议统一抽象以下资源:
- `runtime`
- 目标 JVM 实例
- `cacheNamespace`
- 缓存空间,如某个 CacheManager 下的 cacheName
- `cacheEntry`
- 具体缓存项 key/value
- `managedBean`
- 可读写的托管对象或 MBean
- `operation`
- 受控操作,如 `evict``put``refresh``clear`
- `auditRecord`
- 每次读写与 AI 建议的审计记录
统一资源模型要求:
- 每个资源都有稳定 ID、显示名、provider 来源、能力标签、敏感级别
- 值快照必须区分原始值、展示值和可编辑值
- 资源定位信息必须可写入审计
## 11. AI 协同设计
### 11.1 AI 的角色
AI 在 JVM 场景中只能作为“受控编排者”,不能作为直接执行者。
AI 可以:
- 解释缓存/Bean 的结构和当前状态
- 生成筛选条件和定位建议
- 生成结构化修改计划
- 生成风险说明和回滚建议
- 对执行前后结果做对比分析
AI 不应默认做:
- 直接执行 JVM 修改
- 自由生成任意脚本并直写内存
- 绕过人工确认直接调用 provider
### 11.2 AI 输出形态
AI 不直接输出脚本,而输出结构化变更计划,例如:
```json
{
"targetType": "cacheEntry",
"selector": {
"namespace": "userSessionCache",
"key": "user:1001"
},
"action": "updateValue",
"payload": {
"format": "json",
"value": {
"status": "ACTIVE"
}
},
"reason": "修复错误缓存态"
}
```
### 11.3 AI 执行链路
1. AI 读取结构化上下文
2. AI 产出结构化变更计划
3. Guard Layer 校验目标资源、能力和权限
4. UI 展示修改预览与风险提示
5. 用户确认
6. provider 执行
7. 系统回读验证并写审计
### 11.4 一期 AI 边界
- 支持 AI 分析资源
- 支持 AI 生成修改计划
- 不默认支持 AI 自动执行修改
## 12. 页面与交互设计
### 12.1 连接层
`ConnectionModal` 中新增 `JVM` 类型,建议配置:
- 连接名称
- 目标地址/端口
- 认证信息
- 允许模式列表
- 首选模式
- 环境标签DEV/UAT/PROD
- 默认权限级别(只读/读写)
### 12.2 侧边栏
展示结构:
- 连接
- 模式能力
- 资源类型
- `cacheNamespace` / `managedBean` / `operation`
每个连接或节点显示能力徽标,例如:
- `JMX`
- `Endpoint`
- `Agent`
- `只读`
- `可写`
### 12.3 主工作区 Tab
建议新增以下 Tab 类型:
- `概览`
- `资源浏览`
- `值检查器`
- `修改预览`
- `AI 助手`
- `审计记录`
### 12.4 标准操作流
1. 用户连接 JVM
2. 系统探测 provider 能力
3. 用户选择资源并读取快照
4. 用户手工修改或让 AI 生成计划
5. 系统生成 before/after 预览
6. 用户二次确认
7. provider 执行
8. 系统回读验证
9. 写入审计与操作日志
## 13. 权限与审计
### 13.1 权限模型
权限建议分四层:
- `连接级`
- 决定默认 `readonly` / `readwrite`
- `模式级`
- 决定某 provider 支持哪些动作
- `资源级`
- 某些资源永远只读
- `环境级`
- `PROD` 默认强制二次确认,禁用 AI 自动执行
### 13.2 审计要求
JVM 审计日志不应复用 SQL 日志数据结构,但可以复用现有 LogPanel 样式。
建议记录:
- 连接 ID / 名称
- provider 类型
- 资源定位信息
- 动作类型
- 修改原因
- AI 是否参与
- 执行前摘要
- 执行后摘要
- 结果状态
- 耗时
- 错误信息
建议本地独立落盘为 `jvm_audit.jsonl` 或等价结构,不混入 `sqlLogs`
## 14. 错误处理与兼容性边界
### 14.1 错误分层
- `连接层失败`
- 认证失败、证书失败、JMX/RMI 不通、端点 401/403
- `能力层失败`
- 连接成功但不支持列 key、写值或批量操作
- `执行层失败`
- 资源不存在、值格式非法、provider 拒绝写入
- `验证层失败`
- 执行返回成功但回读校验不一致
所有错误都应显式标明是哪个 provider、哪一层失败避免泛化为“修改失败”。
### 14.2 首期兼容性承诺
优先承诺以下边界:
- Java 8 / 11 / 17 / 21
- Spring Boot 服务优先
- JMX 标准 MBean
- Management Endpoint 模式下优先支持:
- Caffeine
- Ehcache
- Guava Cache
- Spring Cache 抽象下可枚举缓存
- 接入 GoNavi Starter 的自研缓存
- 值类型首期优先:
- string
- number
- boolean
- JSON object / JSON array
- map / list 的结构化展示
### 14.3 首期不承诺
- 任意 Java 对象深度反射编辑
- 无类型信息的二进制对象直接改写
- 跨 classloader 任意对象定位
- 生产环境默认开放批量危险写入
## 15. MVP 分期
### Phase 1连接与只读探测
- JVM 连接类型
- JMX / Endpoint 能力探测
- 资源树浏览
- 值查看
- 概览页与能力徽标
- 不开放写入
### Phase 2受控修改与审计
- 白名单资源写入
- before/after 预览
- 二次确认
- 审计日志
- 回读验证
- 环境级保护策略
### Phase 3AI 协同
- AI 解释资源
- AI 生成修改计划
- AI 风险分析
- AI 回滚建议
- 仍默认不允许 AI 自动执行
### Phase 4高级模式
- Agent Provider
- 预埋 Java Agent 的 runtime 资源治理能力
- 仅在特殊环境启用
## 16. 验证策略
### 16.1 功能验证
- 能连接 JMX 目标
- 能连接 Endpoint 目标
- 能列出缓存空间
- 能查看 key/value
- 能完成受控修改并回读成功
### 16.2 兼容性验证
- Java 8 / 11 / 17 / 21
- 本地、容器、K8s 内网场景
- 开启认证 / 不开启认证
- 仅 JMX、仅 Endpoint、双模式并存
### 16.3 安全验证
- 只读连接无法写入
- `PROD` 环境必须二次确认
- AI 无法绕过人工确认直接执行
- 审计日志完整记录修改链路
### 16.4 稳定性验证
- 目标 JVM 不可达时 UI 不假死
- 资源树大数量时支持分页或懒加载
- 回读失败时标识“不确定状态”
- provider 超时、部分失败、降级路径清晰
## 17. 风险与缓解
### 17.1 风险
- 多 provider 模式会带来能力不一致,用户可能误解“所有 JVM 都能随便改”
- JMX 模式的 key/value 级能力可能明显不足
- 管理端点模式需要业务接入,推广成本高于纯客户端方案
- 若未来引入 Agent 模式,可能引入新的安全审核和兼容性成本
### 17.2 缓解
- 在 UI 中显式展示能力矩阵和当前 provider 来源
- 所有修改都强制经过预览、确认与审计
- 首期将“通用”定义为“统一入口 + 多模式协商”,而不是“单通道万能能力”
- Agent 仅作为高级扩展位,避免污染 MVP 边界
## 18. 最终结论
JVM 缓存可视化编辑能力在 GoNavi 中具备落地基础,但必须采用“统一入口、多 provider、能力协商、强 Guard Layer”的产品化方案。
推荐结论如下:
- 新增独立的 `JVM Connector` 子系统
- 首期支持 `JMX + Management Endpoint`
- `Agent` 作为高级可选模式交付
- AI 首期支持分析与生成修改计划,不默认开放自动执行
- 所有修改必须经过预览、确认、审计和回读验证
这一路径能够在兼顾企业安全约束的前提下,为用户提供可持续演进的 JVM 运行时缓存治理能力。

View File

@@ -0,0 +1,73 @@
# 需求进度追踪 - AI聊天发送快捷键
## 1. 需求摘要
- 需求名称AI 聊天发送快捷键
- 提出日期2026-04-28
- 负责人Claude Code
- 目标:将 AI 聊天发送快捷键纳入工具中心快捷键管理,支持录制自定义 Enter 相关组合键,降低输入法 Enter 上屏时误发送的风险。
- 非目标:不调整后端 AI 服务配置,不改发送按钮行为,不把 AI 发送快捷键放在 AI 设置弹窗的独立入口。
## 2. 范围与验收
- 范围工具中心快捷键管理、AI 聊天输入框、本地前端偏好持久化。
- 验收标准工具中心出现“AI 聊天发送”快捷键;默认 Enter 发送;可录制 Enter / Cmd+Enter / Ctrl+Enter / Alt+Enter 等 Enter 相关组合;普通字符键不可录制为 AI 发送Shift+Enter 始终换行;输入法 composing 状态不发送刷新后快捷键保持AI 设置弹窗不再出现独立“聊天输入”快捷键入口。
- 依赖与约束:沿用 Zustand `lite-db-storage` 中的 `shortcutOptions` 持久化;保持现有 AI 后端接口不变。
## 3. 里程碑与进度
- [x] 阶段 1需求澄清确认输入法 Enter 上屏导致误发送,需要支持录制自定义快捷键,并复用工具中心快捷键体系。
- [x] 阶段 2影响分析影响工具中心快捷键配置、AIChatPanel、AIChatInput、store 和相关测试。
- [x] 阶段 3方案设计采用共享 `shortcutOptions` actionAI 输入框局部消费,不走全局快捷键执行器。
- [x] 阶段 4实施计划计划已按用户反馈调整为工具中心统一方案。
- [x] 阶段 5实现与自检目标红灯测试已补充新方案核心实现已完成。
- [x] 阶段 6评审与交付已完成代码审查反馈修复、目标测试、全量测试、构建、diff 检查和浏览器手工验证。
- [ ] 阶段 7发布与观察发布后观察用户输入法场景反馈。
## 4. 变更清单
- 已完成:新增工具中心 AI 发送 action 目标测试;实现 Enter 默认快捷键、Enter 组合录制规则、AI 输入框按 `shortcutOptions` 判定发送;移除 AI 设置独立入口;修复刷新后录制值被启动配置刷新覆盖的问题;限制 AI 发送快捷键只能录制 0 或 1 个修饰键的 Enter 组合;消费 AI 发送快捷键后阻止事件继续冒泡;更新 store、工具函数和输入框提示测试。
- 进行中:无。
- 待处理:发布后观察输入法场景反馈。
## 5. 风险与阻塞
- 风险:默认 Enter 发送在少数未标记 composing 的输入法中仍可能误发。
- 阻塞:无。
- 缓解措施:用户可在工具中心录制 Cmd+Enter / Ctrl+Enter / Alt+Enter普通 Enter 不再触发发送AI 发送录制限制为 Enter 相关组合并保留 Shift+Enter 换行;输入法 composing 状态始终不发送。
## 6. 决策记录
- 决策 1AI 发送快捷键作为工具中心快捷键 action 持久化,不写入后端 AI provider 配置。
- 决策 2`sendAIChatMessage` 仅由 AI 输入框处理,全局快捷键执行器跳过该局部 action。
- 决策 3AI 发送快捷键允许默认无修饰键 Enter但录制时只接受 Enter 相关组合,拒绝普通字符键和含 Shift 的组合。
- 决策 4输入法 composing 状态始终不发送。
- 决策 5AI 发送快捷键仅允许 Enter / Ctrl+Enter / Cmd+Enter / Alt+Enter拒绝 Ctrl+Alt+Enter 等多修饰键组合,避免扩大局部快捷键冲突面。
- 决策 6AI 输入框命中发送快捷键后同时执行 `preventDefault``stopPropagation`,避免事件继续冒泡到全局快捷键处理器。
## 7. 验证记录
- 验证项:初版两档下拉方案红灯测试。
- 结果:已确认旧实现失败。
- 证据:`aiChatSendShortcut.test.ts` 缺模块失败;`store.test.ts` 新增字段缺失失败;`AIChatInput.notice.test.tsx` placeholder 仍为 Enter 失败。
- 验证项:工具中心统一方案红灯测试。
- 结果:已确认旧实现失败。
- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts` 显示缺少 `sendAIChatMessage` action、`canRecordShortcutForAction` 和自定义 binding 判定失败;`src/store.test.ts` 显示 `shortcutOptions.sendAIChatMessage` 缺失;`src/components/ai/AIChatInput.notice.test.tsx` 显示 placeholder 未渲染 `Meta+Enter 发送`
- 验证项:工具中心统一方案目标绿灯测试。
- 结果:已通过。
- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts`6 passed`src/components/ai/AIChatInput.notice.test.tsx`2 passed`src/store.test.ts`10 passed
- 验证项:代码审查反馈红灯测试。
- 结果:已确认旧实现失败。
- 证据:多修饰键 Enter 组合被误放行、缺少 `consumeAIChatSendShortcutOnKeyDown`、脏持久化 `sendAIChatMessage: A` 未回退到 Enter。
- 验证项:代码审查反馈修复后目标测试。
- 结果:已通过。
- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts src/components/ai/AIChatInput.notice.test.tsx src/store.test.ts`3 files passed22 tests passed
- 验证项:浏览器手工验证。
- 结果:已通过。
- 证据:工具中心录制 `Meta+Enter` 后刷新仍保持AI 输入框 placeholder 显示 `输入消息... (Meta+Enter 发送Shift+Enter 换行,/ 快捷命令)`;普通 Enter 和 Shift+Enter 不触发发送Meta+Enter 触发发送、调用 `preventDefault` 且事件不冒泡。
- 验证项:前端全量测试。
- 结果:已通过。
- 证据:`npm --prefix frontend test -- --run`88 files passed421 tests passed
- 验证项diff 空白检查。
- 结果:已通过。
- 证据:`git diff --check` 无输出。
- 验证项:生产构建。
- 结果:已通过。
- 证据:`npm --prefix frontend run build` 通过,仅有既有 dynamic import / chunk size 警告。
## 8. 下一步
- 下一步行动:提交并推送本次改动,发布后观察用户输入法场景反馈。
- 负责人Claude Code

View File

@@ -0,0 +1,246 @@
# 需求进度追踪 - JVM缓存可视化编辑
## 1. 需求摘要
- 需求名称JVM缓存可视化编辑
- 提出日期2026-04-22
- 负责人Codex
- 目标:完成 GoNavi 连接 Java JVM、可视化查看并修改 JVM 内缓存/对象值的通用能力交付,降低“改缓存只能写接口或重启应用”的运维与排障成本
- 非目标:不承诺覆盖所有 Java 框架/所有对象类型,不绕过目标应用现有安全控制,不在首期开放脚本式任意表达式执行
## 2. 范围与验收
- 范围:
- 交付 JVM 共享契约、连接配置、provider 注册、连接测试与能力探测
- 交付 Endpoint / JMX / Agent 三种接入模式及其资源浏览、读值、预览、执行链路
- 交付 JVM 资源页、预览弹窗、审计查看、AI 草稿生成与回填能力
- 交付 Guard、审计、来源标记、真实集成测试与构建验证
- 验收标准:
- 可以在 GoNavi 中新增 JVM 连接并完成连接测试
- 可以按资源树浏览 JVM 对象并查看结构化快照
- 可以对支持写入的资源执行预览和确认写入,且带 Guard 与审计
- 可以通过 AI 生成结构化修改草稿,但不会跳过人工确认直接执行
- 可以通过真实 JMX 与真实 HTTP contract 完成端到端验证,并通过前后端构建回归
- 依赖与约束:
- 需复用 GoNavi 当前 Wails + React + driver-agent 架构
- 新能力不得破坏现有数据库/Redis 工作流
- 高风险写操作必须具备明确鉴权、审计与回滚思路
- JMX 模式要求 GoNavi 运行机器本地可用 `java` 可执行文件
## 3. 里程碑与进度
- [x] 阶段 1需求澄清完成
- [x] 阶段 2影响分析完成
- [x] 阶段 3方案设计完成已形成正式设计文档
- [x] 阶段 4实施计划完成已形成正式实施计划
- [x] 阶段 5实现与自检完成Task 1 至 Task 7 已完成,代码与构建回归通过)
- [x] 阶段 6评审与交付完成已完成契约复核、上下文隔离修正、文档回填与交付检查
- [ ] 阶段 7发布与观察未开始
## 4. 变更清单
- 已完成:
- 确认 GoNavi 当前存在统一驱动接口与可选 driver-agent 机制
- 确认前端已有 Redis 结构化浏览、命令编辑器、Monaco 编辑器、DataGrid 编辑能力可复用
- 初步判断 JVM 运行时对象编辑不适合直接复用 SQL/Database 抽象,需新增非数据库协议层
- 用户已确认目标方向为“通用型 JVM 接入”
- 用户已确认升级到完整模式,开始高风险架构评估
- 用户明确目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach
- 已形成 JVM 缓存可视化编辑正式设计文档
- 已形成 JVM Connector MVP 正式实施计划文档
- 已完成 Task 1JVM 共享契约与配置归一化
- 已完成 Task 2Provider 注册、连接测试与能力探测 API
- 已完成 Task 3JVM 连接表单、图标与展示文案接入
- 已完成 Task 4只读资源浏览与 JVM Tab
- 已完成 Task 5写入预览、Guard 和审计记录
- 已完成 Task 6AI 结构化变更计划
- 已完成 Task 7全量回归、文档回填与交付检查
- 已完成 JVM AI 计划解析、资源定位解析、AI 计划到当前 JVM 变更草稿的显式映射,避免把 `payload.format/value` 包装层直接透传到现有 JVM 写入契约
- 已完成 AI 聊天面板 JVM 上下文注入、AI 气泡“应用到 JVM 预览”入口以及 JVM 资源页草稿回填闭环
- 已完成 JVM AI 计划来源上下文绑定:消息现在绑定生成时的 `tabId + connectionId + providerMode + resourcePath`,避免切换 JVM 页签后误投递到当前激活页
- 已完成 Endpoint provider 真实 HTTP contract 与补测,支持资源浏览、读值、预览和执行
- 已完成可手工启动的 Java Endpoint fixture 与真实集成补测,可直接验证 Endpoint 模式端到端行为
- 已完成 JMX provider 真实 helper 接入与补测,支持 `domain -> mbean -> attribute/operation` 浏览、attribute `set`、operation `invoke`
- 已完成 JMX helper 预编译 runtime jar 内嵌分发,运行时不再依赖仓库源码目录,也不再要求本地 `javac`
- 已完成 JVM 快照动作提示与 payload 模板回填,前端可直接根据 `supportedActions` 生成草稿
- 已完成 AI 参与来源写入 JVM 审计记录审计页可区分“手工”与“AI 辅助”
- 已完成 Agent provider、Agent 连接表单与概览展示,支持通过独立 Agent Base URL 接入 GoNavi Java Agent
- 已完成真实 Java Agent fixture 与集成验证,可通过 `-javaagent` 方式真实验证 Agent 模式资源浏览、预览与执行
- 已完成 JVM 收口优化Endpoint 能力探测遵循只读配置,概览页能力矩阵补齐模式能力探测与多行错误展示,能力探测失败与风险/结果状态文案统一收口为中文业务语义
- 待处理:
- 无阻塞性交付项;后续仅保留复杂对象参数、`CompositeData` / `TabularData` 等高级类型写入扩展作为增强项
## 5. 风险与阻塞
- 风险:
- 直接修改 JVM 内对象属于高风险运行时操作,误改可能造成业务状态污染
- 不同缓存框架Caffeine/Ehcache/Guava/自研 Map缺少统一标准协议
- 若依赖 attach agent 或表达式执行,需严格控制安全边界与可观测性
- 若目标 JVM 不允许预埋或动态注入 Agent则“通用型”能力边界会明显收缩
- 多接入模式会带来能力不一致问题UI 与权限模型必须显式展示“当前模式支持什么/不支持什么”
- 当前 AI 能力边界仍是“分析 + 生成结构化计划 + 回填预览草稿”,不直接执行 JVM 写入,真实执行仍取决于 Guard、人工确认和 provider 能力
- 当前 AI 计划若只提供 `namespace + key`,仍更适合 endpoint/cache 风格资源JMX 复杂 target 仍建议优先使用 `resourcePath`
- JMX helper 已改为内嵌 jar 分发,但操作者机器仍需本地存在可用 `java`
- Agent 模式要求目标 Java 服务显式以 `-javaagent` 方式启动 GoNavi Java Agent并额外暴露管理端口
- JMX operation preview 仅做参数/签名校验和预览快照,不预测真实副作用
- JMX 参数转换当前覆盖基础类型、`ObjectName` 和部分数组;复杂对象写入仍是后续扩展项
- 历史旧 AI 消息不包含 JVM 来源上下文,若需要应用到预览,需在目标 JVM 资源页重新生成计划
- 阻塞:
- 当前开发收口阶段无新增阻塞
- 缓解措施:
- 优先收敛到标准接入面JMX / Spring Actuator / Java Agent 三选一)
- 首期只支持白名单对象类型与受控写操作
- 要求变更审计、预览、确认与失败回滚路径
- 在交付说明中明确“AI 只生成草稿,不直接执行 JVM 写入”
- JMX helper 改为内嵌 runtime jar默认写入用户缓存目录必要时允许通过 `GONAVI_JMX_HELPER_CLASSPATH` 覆盖 classpath
- 对复杂参数调用保持白名单和人工确认,不开放脚本式自由执行
## 6. 决策记录
- 决策 1先做可行性评估与方案设计不直接进入实现
- 决策 2默认优先复用 GoNavi 现有 driver-agent 与前端编辑器能力,避免侵入式重构主流程
- 决策 3已按完整模式推进后续方案将优先评估通用 Agent 路径是否成立
- 决策 4由于目标服务大概率不允许 agent/attach后续推荐方向转为“多接入模式 + 能力协商”
- 决策 5AI 在 JVM 场景中只负责分析与生成结构化计划,不直接执行运行时写入
- 决策 6AI 计划应用入口只回填 JVM 预览草稿,后续仍必须经过 `JVMPreviewChange`、Guard 校验和人工确认
- 决策 7当前 MVP 中 `updateValue` 会映射到现有 JVM 变更 contract 的 `put`,且 payload 仅接受 JSON 对象
- 决策 8JVM AI 计划必须绑定生成时的 JVM 上下文,只允许投递到匹配的 `tabId + connectionId + providerMode + resourcePath`
- 决策 9JMX helper 采用 Java 8 兼容的预编译 runtime jar 内嵌分发,运行时只依赖本地 `java`
- 决策 10Agent 模式按“预埋 GoNavi Java Agent + 独立 Agent Base URL 接入”落地,不在当前版本实现动态 attach
## 7. 验证记录
- 验证项:
- GoNavi 驱动代理机制核查
- GoNavi 现有 Redis/编辑器/UI 复用能力核查
- JVM Connector 正式设计文档自检
- JVM Connector 实施计划文档自检
- Task 1JVM 共享契约与配置归一化
- Task 2Provider 注册、连接测试与能力探测 API
- Task 6AI 计划解析、资源定位解析、契约映射与页签上下文隔离
- Task 7Java Endpoint fixture 真实集成验证
- Task 7JMX helper 内嵌分发与运行时缓存验证
- Task 7Agent provider 与真实 Java Agent 集成验证
- Task 7后端全量测试
- Task 7前端全量测试
- Task 7前端生产构建
- Task 7Wails 生产构建
- 结果:
- 已确认存在可复用的连接桥接与编辑器基础设施
- 已完成正式设计文档落盘与自检,未发现占位词和明显范围冲突
- 已完成正式实施计划落盘与自检,已补齐共享 DTO、provider factory 和审计落盘等关键实现细节
- 已完成 JVM 连接共享契约、默认只读/默认 JMX 归一化、前端配置收敛与补测
- Task 1 已完成规格审查与代码质量审查,结论均通过
- 已完成 JVM Provider 工厂、JMX/Endpoint provider 骨架、App 层连接测试与能力探测 API
- Task 2 已完成规格审查与代码质量审查,结论均通过
- 已完成 JVM 连接类型卡片、最小表单字段、连接测试分发与展示文案接入
- Task 3 已完成规格审查与代码质量审查;过程中修复了 JVM 标题文案偏差、模式选项暴露范围、编辑态模式静默降级和 endpoint timeout 失真问题
- 已完成 JVM 只读资源浏览链路:后端新增 `JVMListResources` / `JVMGetValue`,前端新增 `jvm-overview` / `jvm-resource` tab 与侧边栏 JVM 模式/资源节点
- Task 4 已完成规格复审;代码质量复审确认真实 provider 浏览能力仍为后续任务范围,另外已修正 JVM 资源 tab 同名问题
- 已完成 Task 5后端新增 `JVMPreviewChange` / `JVMApplyChange` / `JVMListAuditRecords`,补齐 Guard、审计 JSONL 落盘与审计读取能力
- Task 5 已补齐只读拦截、`prod` 环境确认、provider preview 错误透出、审计写入失败显式回传、连接 `allowedModes` 约束和局部快照合并保底
- 前端已完成 JVM 变更草稿区、预览弹窗、执行确认、审计记录页签与按 provider mode 的审计过滤
- 已完成 Task 6AI 计划解析、资源定位解析、`updateValue -> put` 显式映射、JSON 对象 payload 约束和上下文绑定单测
- 已完成 Task 6AI 聊天消息与 JVM 来源页签绑定AI 气泡应用按钮不再依赖点击时的 `activeTabId`,避免跨 JVM 页签误投递
- 已完成 Task 7Java Endpoint fixture可真实验证 `resources / value / preview / apply` 四个 endpoint contract
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` 通过
- 已完成 Task 7JMX helper 改为预编译 jar 内嵌分发,并补齐 classpath 覆盖与缓存落盘单测
- `go test ./internal/jvm -run 'TestEnsureJMXHelperRuntime|TestJMXProvider' -count=1` 通过
- 已完成 Task 7Agent provider、Java agent fixture 与真实 `-javaagent` 集成测试
- `go test ./internal/jvm -run 'TestAgentProvider' -count=1` 通过
- `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts` 通过11 tests
- `go test ./... -count=1` 通过
- `cd frontend && npm test -- --run` 通过61 files259 tests
- `cd frontend && npm run build` 通过;构建中存在既有 chunk size / dynamic import 警告,但未阻塞产物生成
- `wails build -clean` 通过,成功生成 macOS 应用包
- 已完成 JVM 收口优化:模式能力探测现在按当前 mode 做业务化错误翻译,避免概览页继续回显 `non-JRMP server``baseURL is required` 这类原始报错
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` 再次通过Endpoint 能力探测只读语义回归)
- `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1` 再次通过(能力探测模式透传与中文错误翻译回归)
- `cd frontend && npm test -- --run src/components/JVMResourceBrowser.layout.test.tsx` 通过JVM 资源页布局回归)
- `cd frontend && npm test -- --run src/utils/jvmResourcePresentation.test.ts` 通过(风险等级、审计结果等本地化展示回归)
- `cd frontend && npm run build` 再次通过
- `wails build -clean` 再次通过,成功生成最新可验收桌面包
- 证据(日志/截图/链接):
- `cmd/optional-driver-agent/main.go`
- `internal/db/database.go`
- `frontend/src/components/RedisViewer.tsx`
- `frontend/src/components/RedisCommandEditor.tsx`
- `frontend/src/components/QueryEditor.tsx`
- `docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md`
- `docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md`
- `internal/connection/types.go`
- `internal/jvm/types.go`
- `internal/jvm/config.go`
- `internal/jvm/config_test.go`
- `frontend/src/types.ts`
- `frontend/src/utils/jvmConnectionConfig.ts`
- `frontend/src/utils/jvmConnectionConfig.test.ts`
- `go test ./internal/jvm -count=1`
- `go test ./...`
- `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts`
- `cd frontend && npm test -- --run`
- `cd frontend && npm run build`
- `internal/jvm/provider.go`
- `internal/jvm/jmx_provider.go`
- `internal/jvm/http_provider.go`
- `internal/jvm/http_provider_test.go`
- `internal/jvm/jmx_helper.go`
- `internal/jvm/jmx_helper_test.go`
- `internal/jvm/provider_contract_test.go`
- `internal/jvm/jmxhelper_assets/jmx-helper-runtime.jar`
- `internal/jvm/jmxhelper_assets/README.md`
- `internal/jvm/testdata/endpointfixture/src/com/gonavi/fixture/EndpointTestServer.java`
- `internal/jvm/testdata/endpointfixture/src/com/gonavi/fixture/MiniJson.java`
- `tools/jmx-helper/src/com/gonavi/jmxhelper/JmxHelperMain.java`
- `internal/app/methods_jvm.go`
- `internal/app/methods_jvm_test.go`
- `frontend/wailsjs/go/app/App.d.ts`
- `frontend/wailsjs/go/app/App.js`
- `frontend/wailsjs/go/models.ts`
- `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1`
- `go test ./internal/jvm ./internal/app -count=1`
- `wails build -clean`
- `frontend/src/components/DatabaseIcons.tsx`
- `frontend/src/components/ConnectionModal.tsx`
- `frontend/src/utils/jvmRuntimePresentation.ts`
- `frontend/src/utils/jvmRuntimePresentation.test.ts`
- `frontend/src/utils/jvmConnectionConfig.ts`
- `frontend/src/utils/jvmConnectionConfig.test.ts`
- `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts`
- `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts`
- `cd frontend && npm run build`
- `internal/app/methods_jvm.go`
- `internal/app/methods_jvm_test.go`
- `frontend/src/components/Sidebar.tsx`
- `frontend/src/components/TabManager.tsx`
- `frontend/src/components/JVMOverview.tsx`
- `frontend/src/components/JVMResourceBrowser.tsx`
- `frontend/src/components/jvm/JVMModeBadge.tsx`
- `frontend/src/store.ts`
- `frontend/src/types.ts`
- `go test ./internal/app -run 'TestJVM(ListResources|GetValue)' -count=1`
- `go test ./internal/app -run 'TestJVMProbeCapabilities|TestTestJVMConnection' -count=1`
- `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts`
- `cd frontend && npm run build`
- `internal/jvm/guard.go`
- `internal/jvm/guard_test.go`
- `internal/jvm/audit_store.go`
- `internal/jvm/audit_store_test.go`
- `internal/app/methods_jvm.go`
- `internal/app/methods_jvm_test.go`
- `frontend/src/components/JVMAuditViewer.tsx`
- `frontend/src/components/jvm/JVMChangePreviewModal.tsx`
- `go test ./internal/jvm ./internal/app -run 'TestPreviewChangeBlocksReadOnlyConnection|TestPreviewChangeReturnsProviderPreviewErrorWhenWriteAllowed|TestPreviewChangeMarksProdWritesAsConfirmationRequired|TestPreviewChangeMergesProviderSnapshotsWithoutDroppingDefaults|TestJVMApplyChangeReturnsProviderPayload|TestJVMPreviewChangeRejectsModeOutsideAllowedModes|TestJVMListAuditRecordsReturnsLatestRecords|TestJVMApplyChangeSurfacesAuditWriteFailure' -count=1`
- `go test ./internal/jvm ./internal/app -count=1`
- `cd frontend && npm run build`
- `frontend/src/utils/jvmAiPlan.ts`
- `frontend/src/utils/jvmAiPlan.test.ts`
- `frontend/src/components/AIChatPanel.tsx`
- `frontend/src/components/ai/AIMessageBubble.tsx`
- `frontend/src/components/JVMResourceBrowser.tsx`
- `frontend/src/types.ts`
- `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts`
- `go test ./... -count=1`
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1`
- `go test ./internal/jvm -run 'TestEnsureJMXHelperRuntime|TestJMXProvider' -count=1`
- `cd frontend && npm test -- --run src/components/JVMResourceBrowser.layout.test.tsx`
- `cd frontend && npm test -- --run src/utils/jvmResourcePresentation.test.ts`
- `cd frontend && npm test -- --run`
- `wails build -clean`
## 8. 下一步
- 下一步行动:由用户按真实 JVM / endpoint 场景执行验收验证;若验收通过,再决定是否提交、推送或继续扩展高级类型写入
- 负责人Codex

View File

@@ -0,0 +1,24 @@
# SQL 方言适配需求进度追踪
## 背景
- Oracle 等非 MySQL 数据源在表设计 DDL 预览中可能回落到 MySQL 语法,导致修改字段名、字段属性等操作执行失败。
- GitHub 相关问题Refs #402(金仓字段类型/DDL 方言、Refs #409Oracle 删除数据 DATE 字面量)。
## 范围
- 表设计 ALTER TABLE 预览:按 MySQL-family、PostgreSQL-family、Oracle/Dameng、SQL Server、SQLite、DuckDB、ClickHouse、TDengine 分支生成。
- 新建表 DDL 预览:避免 Oracle/Dameng/SQL Server/SQLite/DuckDB/ClickHouse/TDengine 输出 MySQL 表选项。
- SQL 自动补全:按当前连接方言解析关键字和函数,避免 Oracle/SQL Server 出现 MySQL-only 提示。
- 表设计字段类型:按数据源给出候选类型,不再大量回退到 MySQL 通用类型。
- Oracle/Dameng 数据复制/删除 SQLDATE/TIMESTAMP 字段使用 Oracle 时间构造函数。
## 验证
- `npm test -- tableDesignerSchemaSql.test.ts sqlDialect.test.ts dataGridCopyInsert.test.ts`
- `npm run build`
## 风险与后续
- ClickHouse/TDengine 的字段约束、默认值、备注语法差异较大,当前策略是生成有限原生 ALTER并用中文注释阻止 MySQL 专属子句外溢。
- SQL Server 删除旧主键约束需要真实约束名,当前预览会提示先在索引页确认。

View File

@@ -0,0 +1,71 @@
# 需求进度追踪 - 发布脚本测试版号与 Mac 打包无交互
## 1. 需求摘要
- 需求名称:发布脚本测试版号与 Mac 打包无交互
- 提出日期2026-04-24
- 负责人Codex
- 目标:
- `build-release.sh` 不再触发 macOS DMG/Finder 排版交互。
- `build-release.sh` 与开发态应用内版本号统一使用测试版号来源。
- 非目标:
- 不调整 GitHub Release 工作流。
- 不修改正式发布 tag 版本策略。
## 2. 范围与验收
- 范围:
- 发布脚本 `build-release.sh`
- 版本解析逻辑 `internal/app/version.go`
- 共享测试版号文件
- 验收标准:
- `bash build-release.sh` 的 macOS 打包不再调用 `create-dmg` 或触发 Finder 排版。
- 本地开发态版本显示与发布脚本默认版本号一致。
- 保留环境变量覆盖版本号能力。
- 依赖与约束:
- 维持现有 Windows/Linux 构建逻辑不变。
## 3. 里程碑与进度
- [x] 阶段 1需求澄清确认去掉 DMG 排版,统一测试版号来源
- [x] 阶段 2影响分析锁定 `build-release.sh``internal/app/version.go`
- [x] 阶段 3方案设计共享 `version/dev-version.txt`macOS 改 ZIP 打包
- [x] 阶段 4实施计划先补版本回归测试再改实现
- [ ] 阶段 5实现与自检
- [ ] 阶段 6评审与交付
- [ ] 阶段 7发布与观察
## 4. 变更清单
- 已完成:
- 新增共享测试版号文件。
- 新增版本回归测试。
- 改造发布脚本 macOS 打包为无交互 ZIP。
- 进行中:
- 自检验证。
- 待处理:
- 无。
## 5. 风险与阻塞
- 风险:
- 正式发版若未覆盖 `GONAVI_VERSION`,默认会使用测试版号。
- 阻塞:
- 无。
- 缓解措施:
- 允许通过 `GONAVI_VERSION` 环境变量显式覆盖。
## 6. 决策记录
- 决策 1`version/dev-version.txt` 作为本地开发/测试共享版本号来源。
- 决策 2发布脚本的 macOS 产物改为 ZIP避免 `create-dmg` 的 Finder 交互。
## 7. 验证记录
- 验证项:
- 版本回归测试
- 发布脚本语法检查
- 发布脚本运行输出
- 结果:
- 进行中
- 证据(日志/截图/链接):
- 待补充
## 8. 下一步
- 下一步行动:
- 跑通回归测试和脚本验证,确认输出产物与版本号
- 负责人:
- Codex

View File

@@ -1,12 +1,12 @@
{
"name": "gonavi-client",
"version": "0.0.1",
"version": "0.6.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gonavi-client",
"version": "0.0.1",
"version": "0.6.5",
"dependencies": {
"@ant-design/icons": "^5.2.6",
"@dnd-kit/core": "^6.3.1",
@@ -33,8 +33,10 @@
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@types/react-resizable": "^3.0.8",
"@types/react-test-renderer": "^18.0.7",
"@types/uuid": "^9.0.7",
"@vitejs/plugin-react": "^4.2.1",
"react-test-renderer": "^18.2.0",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vitest": "^3.2.4"
@@ -2037,6 +2039,16 @@
"@types/react": "*"
}
},
"node_modules/@types/react-test-renderer": {
"version": "18.0.7",
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-18.0.7.tgz",
"integrity": "sha512-1+ANPOWc6rB3IkSnElhjv6VLlKg2dSv/OWClUyZimbLsQyBn8Js9Vtdsi3UICJ2rIQ3k2la06dkB+C92QfhKmg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -5645,6 +5657,20 @@
"react-dom": ">= 16.3"
}
},
"node_modules/react-shallow-renderer": {
"version": "16.15.0",
"resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz",
"integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"object-assign": "^4.1.1",
"react-is": "^16.12.0 || ^17.0.0 || ^18.0.0"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-syntax-highlighter": {
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz",
@@ -5665,6 +5691,21 @@
"react": ">= 0.14.0"
}
},
"node_modules/react-test-renderer": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-18.2.0.tgz",
"integrity": "sha512-JWD+aQ0lh2gvh4NM3bBM42Kx+XybOxCpgYK7F8ugAlpaTSnWsX+39Z4XkOykGZAHrjwwTZT3x3KxswVWxHPUqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"react-is": "^18.2.0",
"react-shallow-renderer": "^16.15.0",
"scheduler": "^0.23.0"
},
"peerDependencies": {
"react": "^18.2.0"
}
},
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "gonavi-client",
"private": true,
"version": "0.0.1",
"version": "0.6.5",
"type": "module",
"scripts": {
"dev": "vite",
@@ -35,8 +35,10 @@
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@types/react-resizable": "^3.0.8",
"@types/react-test-renderer": "^18.0.7",
"@types/uuid": "^9.0.7",
"@vitejs/plugin-react": "^4.2.1",
"react-test-renderer": "^18.2.0",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vitest": "^3.2.4"

View File

@@ -1 +1 @@
f697e821b4acd5cf614d63d46453e8a4
0295a42fd931778d85157816d79d29e5

View File

@@ -326,35 +326,194 @@ body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-check
color: #fff !important;
}
/* 驱动管理:统一关闭 antd sticky 横向条,仅保留自定义独立横向条 */
.driver-manager-table .ant-table-sticky-scroll {
display: none !important;
.driver-manager-modal .ant-modal-body {
background: var(--ant-color-bg-layout, #f5f5f5);
}
/* 仅在独立横向条激活时隐藏表格自身横向滚动条,避免出现双横向条 */
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-content,
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-body {
overflow-x: auto !important;
-ms-overflow-style: none;
scrollbar-width: none;
}
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-content::-webkit-scrollbar:horizontal,
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-body::-webkit-scrollbar:horizontal {
height: 0 !important;
}
.driver-manager-table-wrap {
width: 100%;
max-width: 100%;
overflow-x: hidden;
}
.driver-manager-footer {
width: 100%;
.driver-manager-shell {
display: flex;
flex-direction: column;
gap: 14px;
}
.driver-manager-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: stretch;
padding: 14px 16px;
border: 1px solid rgba(5, 5, 5, 0.08);
border-radius: 8px;
background: var(--ant-color-bg-container, #fff);
}
.driver-manager-heading {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.driver-manager-stats {
display: grid;
grid-template-columns: repeat(4, minmax(64px, 1fr));
gap: 8px;
min-width: 360px;
}
.driver-manager-stat {
display: flex;
flex-direction: column;
gap: 2px;
justify-content: center;
min-height: 58px;
padding: 8px 10px;
border: 1px solid rgba(5, 5, 5, 0.08);
border-radius: 8px;
background: rgba(5, 5, 5, 0.02);
}
.driver-manager-stat span:first-child {
font-size: 20px;
font-weight: 700;
line-height: 1.2;
}
.driver-manager-stat-warning span:first-child {
color: #d48806;
}
.driver-manager-directory-panel {
border: 1px solid rgba(5, 5, 5, 0.08);
border-radius: 8px;
background: var(--ant-color-bg-container, #fff);
}
.driver-manager-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.driver-manager-search {
min-width: 280px;
flex: 1 1 360px;
}
.driver-manager-toolbar-actions {
justify-content: flex-end;
}
.driver-manager-list-head {
display: flex;
justify-content: space-between;
gap: 12px;
min-height: 24px;
}
.driver-manager-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.driver-manager-card {
border: 1px solid rgba(5, 5, 5, 0.08);
border-radius: 8px;
background: var(--ant-color-bg-container, #fff);
overflow: hidden;
}
.driver-manager-card-warning {
border-color: rgba(250, 173, 20, 0.35);
}
.driver-manager-card-ready {
border-color: rgba(82, 196, 26, 0.22);
}
.driver-manager-card-main {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(300px, 38%);
gap: 16px;
padding: 16px;
}
.driver-manager-card-info {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.driver-manager-title-row,
.driver-manager-meta-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
min-width: 0;
}
.driver-manager-driver-name {
font-size: 16px;
}
.driver-manager-meta-row {
row-gap: 4px;
}
.driver-manager-update-note {
display: grid;
gap: 4px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(250, 173, 20, 0.1);
}
.driver-manager-note-text,
.driver-manager-muted-message {
margin-bottom: 0 !important;
}
.driver-manager-muted-message {
color: var(--ant-color-text-secondary);
}
.driver-manager-card-controls {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.driver-manager-control-block {
display: grid;
gap: 4px;
}
.driver-manager-control-label,
.driver-manager-small-text {
font-size: 12px;
}
.driver-manager-version-control {
display: grid;
gap: 4px;
}
.driver-manager-version-lock {
line-height: 24px;
}
.driver-manager-card-actions {
justify-content: flex-end;
}
.driver-manager-card-actions .ant-btn {
min-width: 88px;
}
.driver-manager-footer-actions {
@@ -363,15 +522,62 @@ body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-check
justify-content: flex-end;
}
.driver-manager-hscroll {
width: 100%;
height: 12px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-gutter: stable;
background: transparent;
@media (max-width: 900px) {
.driver-manager-header,
.driver-manager-card-main {
grid-template-columns: 1fr;
}
.driver-manager-stats {
min-width: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.driver-manager-card-actions {
justify-content: flex-start;
}
}
.driver-manager-hscroll-inner {
height: 1px;
.security-update-action-btn.ant-btn,
.security-update-action-btn.ant-btn-default,
.security-update-action-btn.ant-btn-primary,
.security-update-action-btn.ant-btn-text {
box-shadow: none !important;
}
.security-update-action-btn.ant-btn:focus,
.security-update-action-btn.ant-btn:focus-visible,
.security-update-action-btn.ant-btn-default:focus,
.security-update-action-btn.ant-btn-default:focus-visible,
.security-update-action-btn.ant-btn-primary:focus,
.security-update-action-btn.ant-btn-primary:focus-visible,
.security-update-action-btn.ant-btn-text:focus,
.security-update-action-btn.ant-btn-text:focus-visible {
outline: none !important;
box-shadow: none !important;
}
.security-update-banner {
position: relative;
isolation: isolate;
}
.security-update-result-card {
transition: background 0.22s ease, box-shadow 0.22s ease, transform 0.22s ease;
}
.security-update-result-card-active {
animation: security-update-result-pulse 1.8s ease;
}
@keyframes security-update-result-pulse {
0% {
transform: translateY(0);
}
30% {
transform: translateY(-2px);
}
100% {
transform: translateY(0);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,12 @@ import { useStore, loadAISessionsFromBackend, loadAISessionFromBackend } from '.
import { EventsOn, EventsOff } from '../../wailsjs/runtime';
import { DBGetDatabases, DBGetTables } from '../../wailsjs/go/app/App';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { AIChatMessage, AIToolCall } from '../types';
import type {
AIChatMessage,
AIToolCall,
JVMAIPlanContext,
JVMDiagnosticPlanContext,
} from '../types';
import { DownOutlined } from '@ant-design/icons';
import './AIChatPanel.css';
@@ -20,6 +25,10 @@ import {
buildMissingProviderNotice,
buildModelFetchFailedNotice,
} from '../utils/aiComposerNotice';
import { buildAIReadonlyPreviewSQL } from '../utils/aiSqlLimit';
import { resolveAITableSchemaToolResult } from '../utils/aiTableSchemaTool';
import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut';
import { toAIRequestMessage } from '../utils/aiMessagePayload';
interface AIChatPanelProps {
width?: number;
@@ -66,7 +75,7 @@ export const getDynamicMaxContextChars = (modelName?: string) => {
// 当超出指定字符上限时触发上下文自建压缩
const compressContextIfNeeded = async (sid: string, messagesPayload: any[], maxLimit: number) => {
try {
const chars = messagesPayload.reduce((sum, m) => sum + (m.content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0);
const chars = messagesPayload.reduce((sum, m) => sum + (m.content?.length || 0) + (m.reasoning_content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0);
if (chars < maxLimit) return null;
const Service = (window as any).go?.aiservice?.Service;
@@ -231,6 +240,8 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const nudgeCountRef = useRef(0); // 催促模型使用 function call 的次数
const panelRef = useRef<HTMLDivElement>(null); // 面板 DOM ref用于拖拽时直接操作宽度
const dragWidthRef = useRef(0); // 拖拽过程中的实时宽度(不触发 React 重渲染)
const pendingJVMPlanContextRef = useRef<JVMAIPlanContext | undefined>(undefined);
const pendingJVMDiagnosticPlanContextRef = useRef<JVMDiagnosticPlanContext | undefined>(undefined);
const aiChatHistory = useStore(state => state.aiChatHistory);
const aiActiveSessionId = useStore(state => state.aiActiveSessionId);
@@ -247,6 +258,51 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const tabs = useStore(state => state.tabs);
const activeTabId = useStore(state => state.activeTabId);
const aiPanelVisible = useStore(state => state.aiPanelVisible);
const aiChatSendShortcutBinding = useStore(state => state.shortcutOptions.sendAIChatMessage);
const getCurrentJVMPlanContext = useCallback((): JVMAIPlanContext | undefined => {
const state = useStore.getState();
const activeTab = state.tabs.find(t => t.id === state.activeTabId);
if (!activeTab || activeTab.type !== 'jvm-resource') {
return undefined;
}
const activeConnection = state.connections.find(c => c.id === activeTab.connectionId);
if (activeConnection?.config?.type !== 'jvm') {
return undefined;
}
const resourcePath = String(activeTab.resourcePath || '').trim();
if (!resourcePath) {
return undefined;
}
return {
tabId: activeTab.id,
connectionId: activeTab.connectionId,
providerMode: (activeTab.providerMode || activeConnection.config.jvm?.preferredMode || 'jmx') as JVMAIPlanContext['providerMode'],
resourcePath,
};
}, []);
const getCurrentJVMDiagnosticPlanContext = useCallback((): JVMDiagnosticPlanContext | undefined => {
const state = useStore.getState();
const activeTab = state.tabs.find(t => t.id === state.activeTabId);
if (!activeTab || activeTab.type !== 'jvm-diagnostic') {
return undefined;
}
const activeConnection = state.connections.find(c => c.id === activeTab.connectionId);
if (activeConnection?.config?.type !== 'jvm') {
return undefined;
}
return {
tabId: activeTab.id,
connectionId: activeTab.connectionId,
transport: activeConnection.config.jvm?.diagnostic?.transport || 'agent-bridge',
};
}, []);
// Auto-Context Injection Hook
useEffect(() => {
@@ -306,10 +362,15 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const messages = aiChatHistory[sid] || [];
const getConnectionName = useCallback(() => {
if (!activeContext?.connectionId) return '';
const conn = connections.find(c => c.id === activeContext.connectionId);
let connectionId = activeContext?.connectionId;
if (!connectionId) {
const activeTab = tabs.find(t => t.id === activeTabId);
connectionId = activeTab?.connectionId;
}
if (!connectionId) return '';
const conn = connections.find(c => c.id === connectionId);
return conn ? conn.name : '';
}, [activeContext, connections]);
}, [activeContext, activeTabId, connections, tabs]);
const activeConnName = getConnectionName();
@@ -448,7 +509,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
let isFirstCompletion = false;
// 新增:利用 requestAnimationFrame 缓冲高频事件,避免 React 重绘阻塞导致感官吞吐变慢
const streamBuffer = { thinking: '', content: '' };
const streamBuffer = { thinking: '', reasoningContent: '', content: '' };
let flushPending = false;
const flushStreamBuffer = () => {
@@ -463,6 +524,10 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
updates.phase = 'thinking';
streamBuffer.thinking = '';
}
if (streamBuffer.reasoningContent) {
updates.reasoning_content = (existing.reasoning_content || '') + streamBuffer.reasoningContent;
streamBuffer.reasoningContent = '';
}
if (streamBuffer.content) {
updates.content = (existing.content || '') + streamBuffer.content;
updates.phase = 'generating';
@@ -475,7 +540,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
flushPending = false;
};
const handler = (data: { content?: string; thinking?: string; tool_calls?: AIToolCall[]; done?: boolean; error?: string }) => {
const handler = (data: { content?: string; thinking?: string; reasoning_content?: string; tool_calls?: AIToolCall[]; done?: boolean; error?: string }) => {
// Find connecting message if there's no active assistant string
if (!assistantMsgId) {
const history = useStore.getState().aiChatHistory[sid] || [];
@@ -493,7 +558,16 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
if (assistantMsgId) {
updateAIChatMessage(sid, assistantMsgId, { content: `❌ 错误: ${cleanErr}`, phase: 'idle', loading: false, rawError: rawErr });
} else {
addAIChatMessage(sid, { id: genId(), role: 'assistant', phase: 'idle', content: `❌ 错误: ${cleanErr}`, rawError: rawErr, timestamp: Date.now() });
addAIChatMessage(sid, {
id: genId(),
role: 'assistant',
phase: 'idle',
content: `❌ 错误: ${cleanErr}`,
rawError: rawErr,
timestamp: Date.now(),
jvmPlanContext: pendingJVMPlanContextRef.current,
jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current,
});
}
assistantMsgId = '';
setSending(false);
@@ -505,18 +579,43 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
updateAIChatMessage(sid, assistantMsgId, { tool_calls: data.tool_calls, phase: 'tool_calling' });
} else {
assistantMsgId = genId();
addAIChatMessage(sid, { id: assistantMsgId, role: 'assistant', phase: 'tool_calling', content: '', tool_calls: data.tool_calls, timestamp: Date.now(), loading: true });
addAIChatMessage(sid, {
id: assistantMsgId,
role: 'assistant',
phase: 'tool_calling',
content: '',
tool_calls: data.tool_calls,
timestamp: Date.now(),
loading: true,
jvmPlanContext: pendingJVMPlanContextRef.current,
jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current,
});
}
}
// 处理 thinking模型思考过程
if (data.thinking) {
const displayThinking = data.thinking || data.reasoning_content || '';
if (displayThinking || data.reasoning_content) {
if (!assistantMsgId) {
assistantMsgId = genId();
addAIChatMessage(sid, { id: assistantMsgId, role: 'assistant', phase: 'thinking', content: '', thinking: data.thinking, timestamp: Date.now(), loading: true });
addAIChatMessage(sid, {
id: assistantMsgId,
role: 'assistant',
phase: 'thinking',
content: '',
thinking: displayThinking || undefined,
reasoning_content: data.reasoning_content || undefined,
timestamp: Date.now(),
loading: true,
jvmPlanContext: pendingJVMPlanContextRef.current,
jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current,
});
if (sending) setSending(false);
} else {
streamBuffer.thinking += data.thinking;
streamBuffer.thinking += displayThinking;
if (data.reasoning_content) {
streamBuffer.reasoningContent += data.reasoning_content;
}
if (sending) setSending(false);
}
}
@@ -524,7 +623,16 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
if (data.content) {
if (!assistantMsgId) {
assistantMsgId = genId();
addAIChatMessage(sid, { id: assistantMsgId, role: 'assistant', phase: 'generating', content: data.content, timestamp: Date.now(), loading: true });
addAIChatMessage(sid, {
id: assistantMsgId,
role: 'assistant',
phase: 'generating',
content: data.content,
timestamp: Date.now(),
loading: true,
jvmPlanContext: pendingJVMPlanContextRef.current,
jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current,
});
setSending(false);
const currentHistory = useStore.getState().aiChatHistory[sid] || [];
if (currentHistory.length <= 1) isFirstCompletion = true;
@@ -534,7 +642,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
}
}
if (streamBuffer.thinking || streamBuffer.content) {
if (streamBuffer.thinking || streamBuffer.reasoningContent || streamBuffer.content) {
if (!flushPending) {
flushPending = true;
requestAnimationFrame(flushStreamBuffer);
@@ -543,7 +651,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
if (data.done) {
// 如果有残留未 flush 的 buffer立刻推入状态树
if (streamBuffer.thinking || streamBuffer.content) {
if (streamBuffer.thinking || streamBuffer.reasoningContent || streamBuffer.content) {
flushStreamBuffer();
}
const doneAssistantId = assistantMsgId;
@@ -578,13 +686,11 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
(async () => {
try {
const currentHistory = useStore.getState().aiChatHistory[sid] || [];
const messagesPayload = currentHistory.map(m => {
const mapped: any = { role: m.role, content: m.content, images: m.images };
if (m.tool_calls) mapped.tool_calls = m.tool_calls;
if (m.tool_call_id) mapped.tool_call_id = m.tool_call_id;
return mapped;
});
const sysMessages = await buildSystemContextMessages();
const messagesPayload = currentHistory.map(toAIRequestMessage);
const sysMessages = await buildSystemContextMessages(
existing.jvmPlanContext,
existing.jvmDiagnosticPlanContext,
);
// 追加催促消息
messagesPayload.push({ role: 'user', content: '请直接使用 function call 调用工具执行操作,不要只用文字描述计划。' });
const allMsg = [...sysMessages, ...messagesPayload];
@@ -685,21 +791,31 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
toolCallRoundRef.current = 0;
totalToolRoundRef.current = 0;
nudgeCountRef.current = 0;
const retryJVMPlanContext = msg.jvmPlanContext || getCurrentJVMPlanContext();
const retryJVMDiagnosticPlanContext =
msg.jvmDiagnosticPlanContext || getCurrentJVMDiagnosticPlanContext();
pendingJVMPlanContextRef.current = retryJVMPlanContext;
pendingJVMDiagnosticPlanContextRef.current = retryJVMDiagnosticPlanContext;
setSending(true);
// 插入 connecting 过渡消息(波纹动画),与 handleSend 保持一致
const connectingMsg: AIChatMessage = {
id: genId(), role: 'assistant', phase: 'connecting', content: '',
timestamp: Date.now(), loading: true
timestamp: Date.now(), loading: true,
jvmPlanContext: retryJVMPlanContext,
jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext,
};
addAIChatMessage(sid, connectingMsg);
const truncatedHistory = historyLocal.slice(0, lastUserMsgIndex + 1);
const messagesPayload = truncatedHistory.map(m => ({ role: m.role, content: m.content, images: m.images }));
const messagesPayload = truncatedHistory.map(toAIRequestMessage);
try {
const sysMessages = await buildSystemContextMessages();
const sysMessages = await buildSystemContextMessages(
retryJVMPlanContext,
retryJVMDiagnosticPlanContext,
);
const allMessages = [...sysMessages, ...messagesPayload];
const Service = (window as any).go?.aiservice?.Service;
@@ -712,8 +828,12 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
addAIChatMessage(sid, {
id: genId(), role: 'assistant',
content: result?.success ? result.content : `${errClean}`,
thinking: result?.success ? result.reasoning_content : undefined,
reasoning_content: result?.success ? result.reasoning_content : undefined,
rawError: (!result?.success && errClean !== errRaw) ? errRaw : undefined,
timestamp: Date.now()
timestamp: Date.now(),
jvmPlanContext: retryJVMPlanContext,
jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext,
});
setSending(false);
} else {
@@ -722,24 +842,134 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
} catch(e: any) {
const rawE = e?.message || String(e);
const cleanE = sanitizeErrorMsg(rawE);
addAIChatMessage(sid, { id: genId(), role: 'assistant', content: `❌ 发送失败: ${cleanE}`, rawError: cleanE !== rawE ? rawE : undefined, timestamp: Date.now() });
addAIChatMessage(sid, {
id: genId(),
role: 'assistant',
content: `❌ 发送失败: ${cleanE}`,
rawError: cleanE !== rawE ? rawE : undefined,
timestamp: Date.now(),
jvmPlanContext: retryJVMPlanContext,
jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext,
});
setSending(false);
}
}
}, [sid, truncateAIChatMessages, addAIChatMessage]);
}, [
sid,
truncateAIChatMessages,
addAIChatMessage,
getCurrentJVMPlanContext,
getCurrentJVMDiagnosticPlanContext,
]);
const buildSystemContextMessages = useCallback(async () => {
const buildSystemContextMessages = useCallback(async (
overrideJVMPlanContext?: JVMAIPlanContext,
overrideJVMDiagnosticPlanContext?: JVMDiagnosticPlanContext,
) => {
// 🔧 性能优化:从 store 实时读取,避免闭包捕获导致的依赖链式重建
const { activeContext: ctx, aiContexts: ctxMap, connections: conns, tabs: allTabs, activeTabId: tabId } = useStore.getState();
const connectionKey = ctx?.connectionId ? `${ctx.connectionId}:${ctx.dbName || ''}` : 'default';
const activeContextItems = ctxMap[connectionKey] || [];
const systemMessages: { role: string; content: string; images?: string[] }[] = [];
const matchesDiagnosticContext = (tab: typeof allTabs[number]) => {
if (!overrideJVMDiagnosticPlanContext || tab.type !== 'jvm-diagnostic') {
return false;
}
const tabConnection = conns.find(c => c.id === tab.connectionId);
const tabTransport = tabConnection?.config?.jvm?.diagnostic?.transport || 'agent-bridge';
return (
tab.connectionId === overrideJVMDiagnosticPlanContext.connectionId &&
tabTransport === overrideJVMDiagnosticPlanContext.transport
);
};
const activeTab = overrideJVMDiagnosticPlanContext
? (
allTabs.find(t => t.id === overrideJVMDiagnosticPlanContext.tabId && matchesDiagnosticContext(t)) ||
allTabs.find(t => matchesDiagnosticContext(t))
)
: overrideJVMPlanContext
? (
allTabs.find(t => t.id === overrideJVMPlanContext.tabId) ||
allTabs.find(
t =>
t.type === 'jvm-resource' &&
t.connectionId === overrideJVMPlanContext.connectionId &&
t.providerMode === overrideJVMPlanContext.providerMode &&
String(t.resourcePath || '').trim() === overrideJVMPlanContext.resourcePath,
)
)
: allTabs.find(t => t.id === tabId);
const activeConnection = activeTab?.connectionId
? conns.find(c => c.id === activeTab.connectionId)
: undefined;
if (
activeTab &&
activeTab.type === 'jvm-diagnostic' &&
activeConnection?.config?.type === 'jvm'
) {
const diagnostic = activeConnection.config.jvm?.diagnostic;
const diagnosticTransport = overrideJVMDiagnosticPlanContext?.transport || diagnostic?.transport || 'agent-bridge';
const readOnly = activeConnection.config.jvm?.readOnly !== false;
const environment = activeConnection.config.jvm?.environment || 'unknown';
systemMessages.push({
role: 'system',
content: `你是 GoNavi 的 JVM 诊断助手。当前页签是 Arthas 兼容诊断工作台,目标是输出可回填到诊断控制台的结构化诊断计划。
当前连接:${activeConnection.name}
目标主机:${activeConnection.config.host || '-'}
诊断 transport${diagnosticTransport}
运行环境:${environment}
连接策略:${readOnly ? '默认按只读诊断思路回答只生成观察、trace、排障命令不要假设已经执行。' : '允许生成诊断命令,但仍然必须先给计划,再由用户决定是否执行。'}
命令权限observe=${diagnostic?.allowObserveCommands !== false ? '允许' : '禁止'}trace=${diagnostic?.allowTraceCommands === true ? '允许' : '禁止'}mutating=${diagnostic?.allowMutatingCommands === true ? '允许' : '禁止'}
回答规则:
1. 可以先给一小段分析,但必须包含且只包含一个 \`\`\`json 代码块。
2. JSON 字段严格限定为 intent、transport、command、riskLevel、reason、expectedSignals。
3. transport 必须填写当前值 ${diagnosticTransport},不要编造其他 transport。
4. command 必须是单条诊断命令,不要带 shell 提示符、换行拼接、多条命令或代码围栏。
5. riskLevel 只能是 low、medium、high。
6. expectedSignals 必须是字符串数组,描述执行后需要重点观察的信号。
7. 如果命令权限不允许某类操作,就不要输出该类命令;无法满足时直接说明限制。`,
});
return systemMessages;
}
if (
activeTab &&
(activeTab.type === 'jvm-resource' || activeTab.type === 'jvm-overview' || activeTab.type === 'jvm-audit') &&
activeConnection?.config?.type === 'jvm'
) {
const providerMode = activeTab.providerMode || activeConnection.config.jvm?.preferredMode || 'jmx';
const resourcePath = activeTab.resourcePath || '';
const readOnly = activeConnection.config.jvm?.readOnly !== false;
const environment = activeConnection.config.jvm?.environment || 'unknown';
systemMessages.push({
role: 'system',
content: `你是 GoNavi 的 JVM 运行时分析助手。当前上下文不是 SQL而是 JVM 资源工作台。
当前连接:${activeConnection.name}
目标主机:${activeConnection.config.host || '-'}
Provider 模式:${providerMode}
运行环境:${environment}
连接策略:${readOnly ? '只读连接,只能分析和生成变更计划,绝不能假设已执行写入。' : '可写连接,但任何修改都必须先生成预览并等待人工确认。'}
${resourcePath ? `当前资源路径:${resourcePath}` : '当前未选中具体资源路径。'}
回答规则:
1. 你可以解释资源结构、风险、修改建议和回滚建议。
2. 如果用户要求生成 JVM 修改方案,必须输出一个唯一的 \`\`\`json 代码块,并且 JSON 字段严格限定为 targetType、selector、action、payload、reason。
3. action 优先使用当前资源快照或元数据里已经声明的 supportedActions如果当前资源没有声明再基于快照内容谨慎推断。
4. selector.resourcePath 优先使用当前资源路径;如果当前路径未知,就明确说明无法精确定位,不要编造路径。
5. payload 只能使用 {"format":"json","value":{...}} 或 {"format":"text","value":"..."} 这两种包装形式,不要输出脚本、命令或裸值。
6. 不要输出脚本、命令或“已经执行成功”之类的表述。`
});
return systemMessages;
}
let targetConnId = ctx?.connectionId;
let targetDbName = ctx?.dbName;
if (!targetConnId || !targetDbName) {
const activeTab = allTabs.find(t => t.id === tabId);
if (activeTab && activeTab.connectionId && activeTab.dbName) {
targetConnId = activeTab.connectionId;
targetDbName = activeTab.dbName;
@@ -804,6 +1034,13 @@ SELECT * FROM users WHERE status = 1;
const toolContextMapRef = useRef<Map<string, { connectionId: string; dbName: string; tables: string[] }>>(new Map());
const executeLocalTools = useCallback(async (toolCalls: AIToolCall[], currentAsstMsgId: string) => {
const currentAsstMsg = (useStore.getState().aiChatHistory[sid] || []).find(m => m.id === currentAsstMsgId);
const inheritedJVMPlanContext = currentAsstMsg?.jvmPlanContext || pendingJVMPlanContextRef.current;
const inheritedJVMDiagnosticPlanContext =
currentAsstMsg?.jvmDiagnosticPlanContext || pendingJVMDiagnosticPlanContextRef.current;
pendingJVMPlanContextRef.current = inheritedJVMPlanContext;
pendingJVMDiagnosticPlanContextRef.current = inheritedJVMDiagnosticPlanContext;
// 【全局轮次熔断】防止模型(如 DeepSeek在已生成答案后仍无限循环调用工具
const MAX_TOOL_CALL_ROUNDS = 15;
totalToolRoundRef.current += 1;
@@ -813,6 +1050,8 @@ SELECT * FROM users WHERE status = 1;
id: genId(), role: 'assistant',
content: `⚠️ 工具调用已达 ${MAX_TOOL_CALL_ROUNDS} 轮上限,自动终止循环。如需继续探索,请发送新的消息。`,
timestamp: Date.now(),
jvmPlanContext: inheritedJVMPlanContext,
jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext,
});
setSending(false);
return;
@@ -917,12 +1156,15 @@ SELECT * FROM users WHERE status = 1;
try {
const safeDbName = args.dbName ? String(args.dbName).trim() : '';
const safeTable = args.tableName ? String(args.tableName).trim() : '';
const { DBShowCreateTable } = await import('../../wailsjs/go/app/App');
const ddlRes = await DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, safeDbName, safeTable);
if (ddlRes?.success) {
resStr = typeof ddlRes.data === 'string' ? ddlRes.data : JSON.stringify(ddlRes.data);
success = true;
} else { resStr = ddlRes?.message || 'Failed to fetch DDL'; }
const { DBShowCreateTable, DBGetColumns } = await import('../../wailsjs/go/app/App');
const rpcConfig = buildRpcConnectionConfig(conn.config) as any;
const toolResult = await resolveAITableSchemaToolResult({
tableName: safeTable,
fetchDDL: () => DBShowCreateTable(rpcConfig, safeDbName, safeTable),
fetchColumns: () => DBGetColumns(rpcConfig, safeDbName, safeTable),
});
resStr = toolResult.content;
success = toolResult.success;
} catch (e: any) {
resStr = `获取建表语句失败: ${e?.message || e}`;
}
@@ -945,14 +1187,8 @@ SELECT * FROM users WHERE status = 1;
}
}
const { DBQuery } = await import('../../wailsjs/go/app/App');
// 只对只读查询自动追加 LIMIT写操作UPDATE/DELETE/INSERT等不追加
const sqlTrimmed = safeSql.replace(/;\s*$/, ''); // 去掉末尾分号防止拼接出 "; LIMIT 50"
const sqlFirstWord = sqlTrimmed.trimStart().split(/\s/)[0]?.toLowerCase() || '';
const isReadQuery = ['select', 'show', 'describe', 'desc', 'explain', 'with'].includes(sqlFirstWord);
const finalSql = (isReadQuery && !sqlTrimmed.toLowerCase().includes('limit'))
? sqlTrimmed + ' LIMIT 50'
: sqlTrimmed;
const qRes = await DBQuery(buildRpcConnectionConfig(conn.config) as any, safeDbName, safeSql + (safeSql.toLowerCase().includes('limit') ? '' : ' LIMIT 50'));
const finalSql = buildAIReadonlyPreviewSQL(conn.config?.type || '', safeSql, 50, conn.config?.driver || '');
const qRes = await DBQuery(buildRpcConnectionConfig(conn.config) as any, safeDbName, finalSql);
if (qRes?.success) {
const rows = Array.isArray(qRes.data) ? qRes.data : [];
const limitedRows = rows.slice(0, 50);
@@ -1001,6 +1237,8 @@ SELECT * FROM users WHERE status = 1;
id: genId(), role: 'assistant',
content: '⚠️ 探针连续 3 轮执行失败,自动终止。请检查连接状态后重试。',
timestamp: Date.now(),
jvmPlanContext: inheritedJVMPlanContext,
jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext,
});
setSending(false);
return;
@@ -1014,7 +1252,9 @@ SELECT * FROM users WHERE status = 1;
const chainConnectingMsg: AIChatMessage = {
id: genId(), role: 'assistant', phase: 'connecting',
content: '汇总探针执行结果中',
timestamp: Date.now(), loading: true
timestamp: Date.now(), loading: true,
jvmPlanContext: inheritedJVMPlanContext,
jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext,
};
useStore.getState().addAIChatMessage(sid, chainConnectingMsg);
@@ -1035,13 +1275,11 @@ SELECT * FROM users WHERE status = 1;
setSending(true);
const currentHistory = useStore.getState().aiChatHistory[sid] || [];
// 过滤掉 connecting 占位消息,不发给模型
const messagesPayload = currentHistory.filter(m => m.phase !== 'connecting').map(m => {
const mapped: any = { role: m.role, content: m.content, images: m.images };
if (m.tool_calls) mapped.tool_calls = m.tool_calls;
if (m.tool_call_id) mapped.tool_call_id = m.tool_call_id;
return mapped;
});
const sysMessages = await buildSystemContextMessages();
const messagesPayload = currentHistory.filter(m => m.phase !== 'connecting').map(toAIRequestMessage);
const sysMessages = await buildSystemContextMessages(
inheritedJVMPlanContext,
inheritedJVMDiagnosticPlanContext,
);
let finalMessagesPayload = messagesPayload;
// 在这里加入长度检查和自动摘要(带上动态限额)
@@ -1077,8 +1315,12 @@ SELECT * FROM users WHERE status = 1;
useStore.getState().addAIChatMessage(sid, {
id: genId(), role: 'assistant',
content: result?.success ? result.content : `${errC}`,
thinking: result?.success ? result.reasoning_content : undefined,
reasoning_content: result?.success ? result.reasoning_content : undefined,
rawError: (!result?.success && errC !== errR) ? errR : undefined,
timestamp: Date.now(),
jvmPlanContext: inheritedJVMPlanContext,
jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext,
});
setSending(false);
}
@@ -1106,6 +1348,10 @@ SELECT * FROM users WHERE status = 1;
toolCallRoundRef.current = 0; // 重置工具调用轮次计数
totalToolRoundRef.current = 0; // 重置总轮次计数
nudgeCountRef.current = 0; // 重置催促计数
const currentJVMPlanContext = getCurrentJVMPlanContext();
const currentJVMDiagnosticPlanContext = getCurrentJVMDiagnosticPlanContext();
pendingJVMPlanContextRef.current = currentJVMPlanContext;
pendingJVMDiagnosticPlanContextRef.current = currentJVMDiagnosticPlanContext;
const currentImages = [...draftImages];
setInput('');
@@ -1124,21 +1370,21 @@ SELECT * FROM users WHERE status = 1;
const connectingMsg: AIChatMessage = {
id: genId(), role: 'assistant', phase: 'connecting', content: '',
timestamp: Date.now(), loading: true
timestamp: Date.now(), loading: true,
jvmPlanContext: currentJVMPlanContext,
jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext,
};
addAIChatMessage(sid, connectingMsg);
const systemMessages = await buildSystemContextMessages();
const systemMessages = await buildSystemContextMessages(
currentJVMPlanContext,
currentJVMDiagnosticPlanContext,
);
// 【过渡状态 2】上下文已组装完成即将接入模型
updateAIChatMessage(sid, connectingMsg.id, { content: '模型接入中' });
const chatMessages = [...messages, userMsg].map(m => {
const mapped: any = { role: m.role, content: m.content, images: m.images };
if (m.tool_calls) mapped.tool_calls = m.tool_calls;
if (m.tool_call_id) mapped.tool_call_id = m.tool_call_id;
return mapped;
});
const chatMessages = [...messages, userMsg].map(toAIRequestMessage);
let finalMessagesPayload = chatMessages;
const dynamicMaxLimit = getDynamicMaxContextChars(activeProvider?.model);
@@ -1174,8 +1420,12 @@ SELECT * FROM users WHERE status = 1;
const assistantMsg: AIChatMessage = {
id: genId(), role: 'assistant',
content: result?.success ? result.content : `${errC2}`,
thinking: result?.success ? result.reasoning_content : undefined,
reasoning_content: result?.success ? result.reasoning_content : undefined,
rawError: (!result?.success && errC2 !== errR2) ? errR2 : undefined,
timestamp: Date.now(),
jvmPlanContext: currentJVMPlanContext,
jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext,
};
addAIChatMessage(sid, assistantMsg);
setSending(false);
@@ -1185,23 +1435,46 @@ SELECT * FROM users WHERE status = 1;
generateTitleForSession(sid);
}
} else {
addAIChatMessage(sid, { id: genId(), role: 'assistant', content: '❌ AI Service 未就绪', timestamp: Date.now() });
addAIChatMessage(sid, {
id: genId(),
role: 'assistant',
content: '❌ AI Service 未就绪',
timestamp: Date.now(),
jvmPlanContext: currentJVMPlanContext,
jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext,
});
setSending(false);
}
} catch (e: any) {
const rawE2 = e?.message || String(e);
const cleanE2 = sanitizeErrorMsg(rawE2);
addAIChatMessage(sid, { id: genId(), role: 'assistant', content: `❌ 发送失败: ${cleanE2}`, rawError: cleanE2 !== rawE2 ? rawE2 : undefined, timestamp: Date.now() });
addAIChatMessage(sid, {
id: genId(),
role: 'assistant',
content: `❌ 发送失败: ${cleanE2}`,
rawError: cleanE2 !== rawE2 ? rawE2 : undefined,
timestamp: Date.now(),
jvmPlanContext: currentJVMPlanContext,
jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext,
});
setSending(false);
}
}, [input, draftImages, sending, messages, addAIChatMessage, sid, activeProvider]);
}, [
input,
draftImages,
sending,
messages,
addAIChatMessage,
sid,
activeProvider,
buildSystemContextMessages,
getCurrentJVMPlanContext,
getCurrentJVMDiagnosticPlanContext,
]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}, [handleSend]);
consumeAIChatSendShortcutOnKeyDown(aiChatSendShortcutBinding, e, handleSend);
}, [aiChatSendShortcutBinding, handleSend]);
const handleStop = useCallback(async () => {
try {
@@ -1316,7 +1589,7 @@ SELECT * FROM users WHERE status = 1;
return connection ? buildRpcConnectionConfig(connection.config) : undefined;
}, [inferredConnectionId, connections]);
const contextUsageChars = useMemo(() =>
messages.reduce((sum, m) => sum + (m.content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0),
messages.reduce((sum, m) => sum + (m.content?.length || 0) + (m.reasoning_content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0),
[messages]);
const contextTableNames = useMemo(() => {
const ck = activeContext?.connectionId ? `${activeContext.connectionId}:${activeContext.dbName || ''}` : 'default';
@@ -1432,6 +1705,7 @@ SELECT * FROM users WHERE status = 1;
activeProvider={activeProvider}
dynamicModels={dynamicModels}
loadingModels={loadingModels}
sendShortcutBinding={aiChatSendShortcutBinding}
composerNotice={composerNotice}
onModelChange={handleModelChange}
onFetchModels={fetchDynamicModels}

View File

@@ -20,7 +20,6 @@ import {
} from '../utils/aiSettingsPresetLayout';
import { resolveProviderSecretDraft } from '../utils/providerSecretDraft';
import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
interface AISettingsModalProps {
@@ -28,6 +27,7 @@ interface AISettingsModalProps {
onClose: () => void;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
focusProviderId?: string;
}
// 预设配置:每个预设映射到后端 typeopenai/anthropic/gemini/custom并附带默认 URL 和 Model
@@ -79,7 +79,7 @@ const CONTEXT_OPTIONS: { label: string; value: AIContextLevel; desc: string; ico
{ label: '含查询结果', value: 'with_results', desc: '传递最近的查询结果作为上下文', icon: '📑' },
];
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme }) => {
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => {
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
const [activeProviderId, setActiveProviderId] = useState<string>('');
const [safetyLevel, setSafetyLevel] = useState<AISafetyLevel>('readonly');
@@ -135,6 +135,17 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]);
useEffect(() => {
if (!open || !focusProviderId) {
return;
}
if (!providers.some((provider) => provider.id === focusProviderId)) {
return;
}
setActiveSection('providers');
setActiveProviderId(focusProviderId);
}, [focusProviderId, open, providers]);
const applyProviderEditorSession = useCallback((session: ProviderEditorSession) => {
setEditingProvider(session.editingProvider as AIProviderConfig | null);
setIsEditing(session.isEditing);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { Checkbox, Input, Modal, Typography } from 'antd';
const { Text } = Typography;
type ConnectionPackagePasswordModalMode = 'import' | 'export';
export interface ConnectionPackagePasswordModalProps {
open: boolean;
title: string;
mode?: ConnectionPackagePasswordModalMode;
includeSecrets?: boolean;
useFilePassword?: boolean;
password: string;
error?: string;
confirmLoading?: boolean;
confirmText?: string;
cancelText?: string;
onIncludeSecretsChange?: (value: boolean) => void;
onUseFilePasswordChange?: (value: boolean) => void;
onPasswordChange: (value: string) => void;
onConfirm: () => void;
onCancel: () => void;
}
export default function ConnectionPackagePasswordModal({
open,
title,
mode = 'import',
includeSecrets = true,
useFilePassword = false,
password,
error,
confirmLoading,
confirmText = '确认',
cancelText = '取消',
onIncludeSecretsChange,
onUseFilePasswordChange,
onPasswordChange,
onConfirm,
onCancel,
}: ConnectionPackagePasswordModalProps) {
const isExportMode = mode === 'export';
const showFilePasswordInput = isExportMode ? useFilePassword : true;
const placeholder = isExportMode ? '请输入文件保护密码(可选)' : '请输入恢复包密码';
const helperText = !includeSecrets
? '将仅导出连接配置,不包含密码。'
: (useFilePassword
? '请通过单独渠道将密码告知接收方,不要和文件一起发送。'
: '密码已加密保护。如需通过公网传输,建议设置文件保护密码。');
return (
<Modal
open={open}
title={title}
okText={confirmText}
cancelText={cancelText}
confirmLoading={confirmLoading}
onOk={onConfirm}
onCancel={onCancel}
destroyOnClose={false}
maskClosable={false}
>
{isExportMode ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Checkbox
checked={includeSecrets}
onChange={(event) => onIncludeSecretsChange?.(event.target.checked)}
>
</Checkbox>
<Checkbox
checked={useFilePassword}
disabled={!includeSecrets}
onChange={(event) => onUseFilePasswordChange?.(event.target.checked)}
>
</Checkbox>
</div>
) : null}
{showFilePasswordInput ? (
<Input.Password
autoFocus
value={password}
placeholder={placeholder}
disabled={isExportMode && !useFilePassword}
onChange={(event) => onPasswordChange(event.target.value)}
/>
) : null}
{isExportMode ? (
<Text type={useFilePassword ? 'warning' : 'secondary'} style={{ display: 'block', marginTop: 8 }}>
{helperText}
</Text>
) : null}
{error ? (
<Text type="danger" style={{ display: 'block', marginTop: 8 }}>
{error}
</Text>
) : null}
</Modal>
);
}

View File

@@ -0,0 +1,464 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import DataGrid, { buildDataGridCommitChangeSet, GONAVI_ROW_KEY } from './DataGrid';
import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
const storeState = vi.hoisted(() => ({
connections: [
{
id: 'conn-1',
name: 'local',
config: {
type: 'mysql',
host: '127.0.0.1',
port: 3306,
user: 'root',
password: '',
database: 'main',
},
},
],
addSqlLog: vi.fn(),
theme: 'light',
appearance: {
enabled: true,
opacity: 1,
blur: 0,
showDataTableVerticalBorders: false,
dataTableColumnWidthMode: 'standard',
},
queryOptions: {
showColumnComment: false,
showColumnType: false,
},
setQueryOptions: vi.fn(),
tableColumnOrders: {},
enableColumnOrderMemory: false,
setTableColumnOrder: vi.fn(),
setEnableColumnOrderMemory: vi.fn(),
clearTableColumnOrder: vi.fn(),
tableHiddenColumns: {},
enableHiddenColumnMemory: false,
setTableHiddenColumns: vi.fn(),
setEnableHiddenColumnMemory: vi.fn(),
clearTableHiddenColumns: vi.fn(),
aiPanelVisible: false,
setAIPanelVisible: vi.fn(),
}));
const backendApp = vi.hoisted(() => ({
ImportData: vi.fn(),
ExportTable: vi.fn(),
ExportData: vi.fn(),
ExportQuery: vi.fn(),
ApplyChanges: vi.fn(),
DBGetColumns: vi.fn(),
DBGetIndexes: vi.fn(),
DBShowCreateTable: vi.fn(),
}));
const messageApi = vi.hoisted(() => ({
error: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
loading: vi.fn(() => vi.fn()),
}));
vi.mock('../store', () => ({
useStore: (selector: (state: typeof storeState) => any) => selector(storeState),
}));
vi.mock('../../wailsjs/go/app/App', () => backendApp);
vi.mock('@monaco-editor/react', () => ({
default: ({ value }: { value?: string }) => <pre>{value}</pre>,
}));
vi.mock('./ImportPreviewModal', () => ({
default: () => null,
}));
vi.mock('@ant-design/icons', () => {
const Icon = () => <span />;
return {
ReloadOutlined: Icon,
ImportOutlined: Icon,
ExportOutlined: Icon,
DownOutlined: Icon,
PlusOutlined: Icon,
DeleteOutlined: Icon,
SaveOutlined: Icon,
UndoOutlined: Icon,
FilterOutlined: Icon,
CloseOutlined: Icon,
ConsoleSqlOutlined: Icon,
FileTextOutlined: Icon,
CopyOutlined: Icon,
ClearOutlined: Icon,
EditOutlined: Icon,
VerticalAlignBottomOutlined: Icon,
LeftOutlined: Icon,
RightOutlined: Icon,
RobotOutlined: Icon,
SearchOutlined: Icon,
};
});
vi.mock('@dnd-kit/core', () => ({
DndContext: ({ children }: any) => <>{children}</>,
PointerSensor: vi.fn(),
MouseSensor: vi.fn(),
TouchSensor: vi.fn(),
useSensor: vi.fn(() => ({})),
useSensors: vi.fn(() => []),
closestCenter: vi.fn(),
}));
vi.mock('@dnd-kit/sortable', () => ({
SortableContext: ({ children }: any) => <>{children}</>,
useSortable: vi.fn(() => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
transform: null,
transition: undefined,
isDragging: false,
})),
horizontalListSortingStrategy: vi.fn(),
arrayMove: (items: any[], from: number, to: number) => {
const next = [...items];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
},
}));
vi.mock('@dnd-kit/utilities', () => ({
CSS: {
Transform: {
toString: () => '',
},
},
}));
vi.mock('antd', () => {
const Button = ({ children, disabled, loading, onClick, type, ...rest }: any) => (
<button type="button" disabled={disabled || loading} data-button-type={type} onClick={onClick} {...rest}>
{children}
</button>
);
const Input: any = ({ value, onChange, placeholder, ...rest }: any) => (
<input value={value} onChange={onChange} placeholder={placeholder} {...rest} />
);
Input.TextArea = ({ value, onChange, placeholder }: any) => (
<textarea value={value} onChange={onChange} placeholder={placeholder} />
);
const createForm = () => ({
resetFields: vi.fn(),
setFieldsValue: vi.fn(),
getFieldsValue: vi.fn(() => ({})),
getFieldValue: vi.fn(),
validateFields: vi.fn(() => Promise.resolve({})),
});
const Form: any = ({ children }: any) => <form>{children}</form>;
Form.Item = ({ children }: any) => <>{children}</>;
Form.useForm = () => [createForm()];
const Modal: any = ({ children, footer, open, title }: any) => (
open ? (
<section data-modal-title={title}>
<h2>{title}</h2>
{children}
<div>{footer}</div>
</section>
) : null
);
Modal.useModal = () => [{ info: vi.fn(() => ({ destroy: vi.fn() })) }, null];
const passthrough = ({ children }: any) => <>{children}</>;
return {
Table: () => <table />,
message: messageApi,
Input,
Button,
Dropdown: passthrough,
Form,
Pagination: () => null,
Select: () => null,
Modal,
Checkbox: ({ checked, onChange }: any) => <input type="checkbox" checked={checked} onChange={onChange} />,
Segmented: () => null,
Tooltip: passthrough,
Popover: passthrough,
DatePicker: () => null,
TimePicker: () => null,
AutoComplete: ({ children }: any) => <>{children}</>,
};
});
const textContent = (node: any): string =>
(node.children || [])
.map((item: any) => (typeof item === 'string' ? item : textContent(item)))
.join('');
const findButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0];
const waitForEffects = async () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
};
const normalizeValue = (_columnName: string, value: any) => value;
const rowKeyToString = (key: any) => String(key);
const commitColumnGuard = (columnName: string) => (
columnName !== GONAVI_ROW_KEY && columnName !== ORACLE_ROWID_LOCATOR_COLUMN
);
describe('DataGrid commit change set', () => {
it('uses unique locator values instead of falling back to the whole row', () => {
const result = buildDataGridCommitChangeSet({
addedRows: [],
modifiedRows: {
'row-1': { [GONAVI_ROW_KEY]: 'row-1', EMAIL: 'a@example.com', NAME: 'new-name', AGE: 42 },
},
deletedRowKeys: new Set(),
data: [{ [GONAVI_ROW_KEY]: 'row-1', EMAIL: 'a@example.com', NAME: 'old-name', AGE: 42 }],
editLocator: {
strategy: 'unique-key',
columns: ['EMAIL'],
valueColumns: ['EMAIL'],
readOnly: false,
},
visibleColumnNames: ['EMAIL', 'NAME', 'AGE'],
rowKeyToString,
normalizeCommitCellValue: normalizeValue,
shouldCommitColumn: commitColumnGuard,
});
expect(result).toEqual({
ok: true,
changes: {
inserts: [],
updates: [{ keys: { EMAIL: 'a@example.com' }, values: { NAME: 'new-name' } }],
deletes: [],
},
});
});
it('uses hidden Oracle ROWID only as locator and excludes it from update values', () => {
const result = buildDataGridCommitChangeSet({
addedRows: [],
modifiedRows: {
'row-1': { [GONAVI_ROW_KEY]: 'row-1', NAME: 'new-name', [ORACLE_ROWID_LOCATOR_COLUMN]: 'BBBB' },
},
deletedRowKeys: new Set(),
data: [{ [GONAVI_ROW_KEY]: 'row-1', NAME: 'old-name', [ORACLE_ROWID_LOCATOR_COLUMN]: 'AAAA' }],
editLocator: {
strategy: 'oracle-rowid',
columns: ['ROWID'],
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
readOnly: false,
},
visibleColumnNames: ['NAME'],
rowKeyToString,
normalizeCommitCellValue: normalizeValue,
shouldCommitColumn: commitColumnGuard,
});
expect(result).toEqual({
ok: true,
changes: {
inserts: [],
updates: [{ keys: { ROWID: 'AAAA' }, values: { NAME: 'new-name' } }],
deletes: [],
},
});
});
it('commits only writable result columns and maps aliases back to table columns', () => {
const result = buildDataGridCommitChangeSet({
addedRows: [],
modifiedRows: {
'row-1': {
[GONAVI_ROW_KEY]: 'row-1',
DISPLAY_NAME: 'new-name',
NAME_UPPER: 'NEW-NAME',
},
},
deletedRowKeys: new Set(),
data: [{
[GONAVI_ROW_KEY]: 'row-1',
ID: 7,
DISPLAY_NAME: 'old-name',
NAME_UPPER: 'OLD-NAME',
}],
editLocator: {
strategy: 'primary-key',
columns: ['ID'],
valueColumns: ['ID'],
writableColumns: {
DISPLAY_NAME: 'NAME',
},
readOnly: false,
},
visibleColumnNames: ['DISPLAY_NAME', 'NAME_UPPER'],
rowKeyToString,
normalizeCommitCellValue: normalizeValue,
shouldCommitColumn: commitColumnGuard,
});
expect(result).toEqual({
ok: true,
changes: {
inserts: [],
updates: [{ keys: { ID: 7 }, values: { NAME: 'new-name' } }],
deletes: [],
},
});
});
it('fails closed when no safe locator is available', () => {
const result = buildDataGridCommitChangeSet({
addedRows: [],
modifiedRows: {
'row-1': { [GONAVI_ROW_KEY]: 'row-1', NAME: 'new-name' },
},
deletedRowKeys: new Set(),
data: [{ [GONAVI_ROW_KEY]: 'row-1', NAME: 'old-name' }],
editLocator: undefined,
visibleColumnNames: ['NAME'],
rowKeyToString,
normalizeCommitCellValue: normalizeValue,
shouldCommitColumn: commitColumnGuard,
});
expect(result).toEqual({ ok: false, error: '当前结果没有可用的安全行定位方式,无法提交修改。' });
});
it('rejects delete rows when unique locator value is null', () => {
const result = buildDataGridCommitChangeSet({
addedRows: [],
modifiedRows: {},
deletedRowKeys: new Set(['row-1']),
data: [{ [GONAVI_ROW_KEY]: 'row-1', EMAIL: null, NAME: 'old-name' }],
editLocator: {
strategy: 'unique-key',
columns: ['EMAIL'],
valueColumns: ['EMAIL'],
readOnly: false,
},
visibleColumnNames: ['EMAIL', 'NAME'],
rowKeyToString,
normalizeCommitCellValue: normalizeValue,
shouldCommitColumn: commitColumnGuard,
});
expect(result).toEqual({ ok: false, error: '定位列 EMAIL 的值为空,无法安全提交修改。' });
});
});
describe('DataGrid DDL interactions', () => {
beforeEach(() => {
backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
backendApp.DBShowCreateTable.mockResolvedValue({ success: true, data: 'CREATE TABLE users' });
vi.stubGlobal('document', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
activeElement: null,
elementFromPoint: vi.fn(() => null),
createElement: vi.fn(() => ({
style: {},
getContext: vi.fn(() => ({ measureText: vi.fn(() => ({ width: 0 })) })),
})),
body: { style: {} },
});
vi.stubGlobal('window', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
innerHeight: 768,
innerWidth: 1024,
getComputedStyle: vi.fn(() => ({ font: '12px sans-serif' })),
});
vi.stubGlobal('navigator', {
platform: 'MacIntel',
userAgent: '',
clipboard: { writeText: vi.fn(() => Promise.resolve()) },
});
vi.stubGlobal('HTMLElement', class {});
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
vi.stubGlobal('cancelAnimationFrame', vi.fn());
});
afterEach(() => {
backendApp.ImportData.mockReset();
backendApp.ExportTable.mockReset();
backendApp.ExportData.mockReset();
backendApp.ExportQuery.mockReset();
backendApp.ApplyChanges.mockReset();
backendApp.DBGetColumns.mockReset();
backendApp.DBGetIndexes.mockReset();
backendApp.DBShowCreateTable.mockReset();
vi.unstubAllGlobals();
});
it('ignores stale DDL responses after the table context changes', async () => {
let resolveFirstRequest: (value: any) => void = () => {};
backendApp.DBShowCreateTable.mockReturnValueOnce(new Promise((resolve) => {
resolveFirstRequest = resolve;
}));
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<DataGrid
data={[{ __gonavi_row_key__: 'row-1', id: 1 }]}
columnNames={['id']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
/>,
);
});
await waitForEffects();
await act(async () => {
findButton(renderer!, '查看 DDL').props.onClick();
});
await act(async () => {
renderer!.update(
<DataGrid
data={[{ __gonavi_row_key__: 'row-2', id: 2 }]}
columnNames={['id']}
loading={false}
tableName="orders"
dbName="main"
connectionId="conn-1"
/>,
);
resolveFirstRequest({ success: true, data: 'CREATE TABLE users' });
});
await waitForEffects();
expect(textContent(renderer!.root)).not.toContain('CREATE TABLE users');
expect(renderer!.root.findAll((node) => node.props['data-modal-title'] === 'DDL - orders')).toHaveLength(0);
});
});

View File

@@ -0,0 +1,195 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import DataGrid from './DataGrid';
vi.mock('../store', () => ({
useStore: (selector: (state: any) => any) => selector({
connections: [],
addSqlLog: vi.fn(),
theme: 'light',
appearance: {
enabled: true,
opacity: 1,
blur: 0,
showDataTableVerticalBorders: false,
dataTableColumnWidthMode: 'standard',
},
queryOptions: {
showColumnComment: false,
showColumnType: false,
},
setQueryOptions: vi.fn(),
tableColumnOrders: {},
enableColumnOrderMemory: false,
setTableColumnOrder: vi.fn(),
setEnableColumnOrderMemory: vi.fn(),
clearTableColumnOrder: vi.fn(),
tableHiddenColumns: {},
enableHiddenColumnMemory: false,
setTableHiddenColumns: vi.fn(),
setEnableHiddenColumnMemory: vi.fn(),
clearTableHiddenColumns: vi.fn(),
aiPanelVisible: false,
setAIPanelVisible: vi.fn(),
}),
}));
vi.mock('../../wailsjs/go/app/App', () => ({
ImportData: vi.fn(),
ExportTable: vi.fn(),
ExportData: vi.fn(),
ExportQuery: vi.fn(),
ApplyChanges: vi.fn(),
DBGetColumns: vi.fn(),
DBGetIndexes: vi.fn(),
DBShowCreateTable: vi.fn(),
}));
vi.mock('@monaco-editor/react', () => ({
default: () => null,
}));
describe('DataGrid layout', () => {
it('renders a secondary action strip for view switching and auxiliary actions', () => {
const markup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
readOnly
pagination={{
current: 1,
pageSize: 100,
total: 1,
}}
onPageChange={() => {}}
/>,
);
expect(markup).toContain('data-grid-secondary-actions="true"');
expect(markup).toContain('data-grid-view-switcher="true"');
expect(markup).toContain('data-grid-page-find="true"');
expect(markup).toContain('data-grid-page-find-prev="true"');
expect(markup).toContain('data-grid-page-find-next="true"');
expect(markup).toContain('当前页查找...');
});
it('renders a DDL action for table data pages only', () => {
const tableMarkup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
/>,
);
expect(tableMarkup).toContain('data-grid-ddl-action="true"');
expect(tableMarkup).toContain('查看 DDL');
const schemaTableMarkup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="public.users"
dbName=""
connectionId="conn-1"
/>,
);
expect(schemaTableMarkup).toContain('data-grid-ddl-action="true"');
expect(schemaTableMarkup).toContain('查看 DDL');
expect(schemaTableMarkup).toContain('data-grid-page-find="true"');
const queryMarkup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
exportScope="queryResult"
/>,
);
expect(queryMarkup).not.toContain('data-grid-ddl-action="true"');
});
it('renders row copy and paste actions in editable table toolbar', () => {
const markup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
pkColumns={['id']}
/>,
);
expect(markup).toContain('data-grid-copy-row-action="true"');
expect(markup).toContain('data-grid-paste-row-action="true"');
expect(markup).toContain('复制行');
expect(markup).toContain('粘贴行');
});
it('renders a quick WHERE condition editor when table filters are visible', () => {
const markup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
showFilter
quickWhereCondition="name like 'a%'"
onApplyQuickWhereCondition={() => {}}
/>,
);
expect(markup).toContain('data-grid-quick-where="true"');
expect(markup).toContain('WHERE');
expect(markup).toContain('输入 WHERE 后面的条件');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -5,13 +5,15 @@ import { useStore } from '../store';
import { DBGetDatabases, DBGetTables, DataSync, DataSyncAnalyze, DataSyncPreview } from '../../wailsjs/go/app/App';
import { SavedConnection } from '../types';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues, resolveTextInputSafeBackdropFilter } from '../utils/appearance';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { formatLocalDateTimeLiteral, normalizeTemporalLiteralText } from './dataGridCopyInsert';
import { buildDataSyncRequest, type SourceDatasetMode, validateDataSyncSelection } from './dataSyncRequest';
const { Title, Text } = Typography;
const { Step } = Steps;
const { Option } = Select;
const { TextArea } = Input;
type SyncLogEvent = { jobId: string; level?: string; message?: string; ts?: number };
type SyncProgressEvent = { jobId: string; percent?: number; current?: number; total?: number; table?: string; stage?: string };
@@ -24,6 +26,7 @@ type TableDiffSummary = {
updates?: number;
deletes?: number;
same?: number;
schemaDiffCount?: number;
message?: string;
targetTableExists?: boolean;
plannedAction?: string;
@@ -47,7 +50,7 @@ const quoteSqlIdent = (dbType: string, ident: string): string => {
const raw = String(ident || '').trim();
if (!raw) return raw;
const t = String(dbType || '').toLowerCase();
if (t === 'mysql' || t === 'mariadb' || t === 'diros' || t === 'sphinx' || t === 'clickhouse' || t === 'tdengine') {
if (t === 'mysql' || t === 'mariadb' || t === 'oceanbase' || t === 'diros' || t === 'sphinx' || t === 'clickhouse' || t === 'tdengine') {
return `\`${raw.replace(/`/g, '``')}\``;
}
if (t === 'sqlserver') {
@@ -123,6 +126,15 @@ const buildSqlPreview = (
? previewData.columnTypes as Record<string, string>
: {};
const statements: string[] = [];
const schemaStatements = Array.isArray(previewData.schemaStatements)
? previewData.schemaStatements
.map((item: any) => String(item || '').trim())
.filter((item: string) => item.length > 0)
: [];
schemaStatements.forEach((statement: string) => {
statements.push(statement.endsWith(';') ? statement : `${statement};`);
});
const insertRows = Array.isArray(previewData.inserts) ? previewData.inserts : [];
const updateRows = Array.isArray(previewData.updates) ? previewData.updates : [];
@@ -190,6 +202,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
const darkMode = themeMode === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const effectiveOpacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const disableLocalBackdropFilter = isMacLikePlatform();
// Step 1: Config
const [sourceConnId, setSourceConnId] = useState<string>('');
@@ -203,6 +216,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
// Step 2: Tables
const [allTables, setAllTables] = useState<string[]>([]);
const [selectedTables, setSelectedTables] = useState<string[]>([]);
const [sourceDatasetMode, setSourceDatasetMode] = useState<SourceDatasetMode>('table');
const [sourceQuery, setSourceQuery] = useState<string>('');
// Options
const [workflowType, setWorkflowType] = useState<WorkflowType>('sync');
@@ -283,7 +298,10 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
setTargetConnId('');
setSourceDb('');
setTargetDb('');
setAllTables([]);
setSelectedTables([]);
setSourceDatasetMode('table');
setSourceQuery('');
setWorkflowType('sync');
setSyncContent('data');
setSyncMode('insert_update');
@@ -331,6 +349,28 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
}
}, [workflowType]);
useEffect(() => {
if (sourceDatasetMode !== 'query') return;
if (workflowType !== 'sync') {
setWorkflowType('sync');
}
if (syncContent !== 'data') {
setSyncContent('data');
}
if (targetTableStrategy !== 'existing_only') {
setTargetTableStrategy('existing_only');
}
if (createIndexes) {
setCreateIndexes(false);
}
if (autoAddColumns) {
setAutoAddColumns(false);
}
if (selectedTables.length > 1) {
setSelectedTables(selectedTables.slice(0, 1));
}
}, [sourceDatasetMode, workflowType, syncContent, targetTableStrategy, createIndexes, autoAddColumns, selectedTables]);
const handleSourceConnChange = async (connId: string) => {
setSourceConnId(connId);
setSourceDb('');
@@ -376,10 +416,12 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
setLoading(true);
try {
const conn = connections.find(c => c.id === sourceConnId);
const connId = isSourceQueryMode ? targetConnId : sourceConnId;
const dbName = isSourceQueryMode ? targetDb : sourceDb;
const conn = connections.find(c => c.id === connId);
if (conn) {
const config = normalizeConnConfig(conn, sourceDb);
const res = await DBGetTables(config as any, sourceDb);
const config = normalizeConnConfig(conn, dbName);
const res = await DBGetTables(config as any, dbName);
if (res.success) {
// DBGetTables returns [{Table: "name"}, ...]
const tableRows = Array.isArray(res.data) ? res.data : [];
@@ -387,6 +429,13 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
.map((row: any) => row?.Table || row?.table || row?.TABLE_NAME || Object.values(row || {})[0])
.filter((name: any) => typeof name === 'string' && name.trim() !== '');
setAllTables(tables as string[]);
setSelectedTables(prev => {
const existing = prev.filter((name) => tables.includes(name));
if (isSourceQueryMode) {
return existing.slice(0, 1);
}
return existing;
});
setCurrentStep(1);
} else {
message.error(res.message);
@@ -404,7 +453,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
};
const analyzeDiff = async () => {
if (selectedTables.length === 0) return;
const selectionError = validateDataSyncSelection({ sourceDatasetMode, selectedTables, sourceQuery, syncContent });
if (selectionError) return message.error(selectionError);
if (!sourceConnId || !targetConnId) return message.error("Select connections first");
if (!sourceDb || !targetDb) return message.error("Select databases first");
@@ -421,18 +471,20 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
autoScrollRef.current = true;
setSyncProgress({ percent: 0, current: 0, total: selectedTables.length, table: '', stage: '差异分析' });
const config = {
const config = buildDataSyncRequest({
sourceConfig: normalizeConnConfig(sConn, sourceDb),
targetConfig: normalizeConnConfig(tConn, targetDb),
tables: selectedTables,
content: syncContent,
mode: "insert_update",
selectedTables,
sourceDatasetMode,
sourceQuery,
syncContent,
syncMode: "insert_update",
autoAddColumns,
targetTableStrategy,
createIndexes,
mongoCollectionName: mongoCollectionName.trim(),
mongoCollectionName,
jobId,
};
});
try {
const res = await DataSyncAnalyze(config as any);
@@ -474,17 +526,19 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
setPreviewLoading(true);
setPreviewData(null);
const config = {
const config = buildDataSyncRequest({
sourceConfig: normalizeConnConfig(sConn, sourceDb),
targetConfig: normalizeConnConfig(tConn, targetDb),
tables: selectedTables,
content: "data",
mode: "insert_update",
selectedTables,
sourceDatasetMode,
sourceQuery,
syncContent,
syncMode: "insert_update",
autoAddColumns,
targetTableStrategy,
createIndexes,
mongoCollectionName: mongoCollectionName.trim(),
};
mongoCollectionName,
});
try {
const res = await DataSyncPreview(config as any, table, 200);
@@ -501,6 +555,11 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
};
const runSync = async () => {
const selectionError = validateDataSyncSelection({ sourceDatasetMode, selectedTables, sourceQuery, syncContent });
if (selectionError) {
message.error(selectionError);
return;
}
if (syncContent !== 'schema' && diffTables.length === 0) {
message.error("请先对比差异,再开始同步");
return;
@@ -539,19 +598,21 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
stage: '准备开始',
});
const config = {
const config = buildDataSyncRequest({
sourceConfig: normalizeConnConfig(sConn, sourceDb),
targetConfig: normalizeConnConfig(tConn, targetDb),
tables: selectedTables,
content: syncContent,
mode: syncMode,
selectedTables,
sourceDatasetMode,
sourceQuery,
syncContent,
syncMode,
autoAddColumns,
targetTableStrategy,
createIndexes,
mongoCollectionName: mongoCollectionName.trim(),
mongoCollectionName,
tableOptions,
jobId,
};
});
try {
const res = await DataSync(config as any);
@@ -595,6 +656,18 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
const ops = tableOptions[previewTable] || { insert: true, update: true, delete: false };
return buildSqlPreview(previewData, previewTable, targetType, ops);
}, [previewData, previewTable, targetConnId, connections, tableOptions]);
const previewHasSchemaStatements = useMemo(
() => Array.isArray(previewData?.schemaStatements) && previewData.schemaStatements.length > 0,
[previewData],
);
const previewSchemaWarnings = useMemo(
() => Array.isArray(previewData?.schemaWarnings) ? previewData.schemaWarnings as string[] : [],
[previewData],
);
const previewHasDataDiff = useMemo(
() => Number(previewData?.totalInserts || 0) + Number(previewData?.totalUpdates || 0) + Number(previewData?.totalDeletes || 0) > 0,
[previewData],
);
const analysisWarnings = useMemo(() => {
const items: string[] = [];
@@ -605,6 +678,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
return Array.from(new Set(items));
}, [diffTables]);
const isSourceQueryMode = sourceDatasetMode === 'query';
const isMigrationWorkflow = workflowType === 'migration';
const sourceConn = useMemo(() => connections.find(c => c.id === sourceConnId), [connections, sourceConnId]);
const targetConn = useMemo(() => connections.find(c => c.id === targetConnId), [connections, targetConnId]);
@@ -630,8 +704,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
boxShadow: darkMode ? '0 24px 56px rgba(0,0,0,0.36)' : '0 18px 44px rgba(15,23,42,0.14)',
backdropFilter: darkMode ? 'blur(18px)' : 'none',
}), [darkMode]);
backdropFilter: resolveTextInputSafeBackdropFilter(darkMode ? 'blur(18px)' : 'none', disableLocalBackdropFilter),
}), [darkMode, disableLocalBackdropFilter]);
const shellCardStyle = useMemo<React.CSSProperties>(() => ({
borderRadius: 18,
@@ -837,7 +911,13 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
<Form.Item label="功能类型">
<Select value={workflowType} onChange={setWorkflowType}>
<Option value="sync"></Option>
<Option value="migration"></Option>
<Option value="migration" disabled={isSourceQueryMode}></Option>
</Select>
</Form.Item>
<Form.Item label="源数据方式">
<Select value={sourceDatasetMode} onChange={setSourceDatasetMode}>
<Option value="table"></Option>
<Option value="query"> SQL </Option>
</Select>
</Form.Item>
<Alert
@@ -848,11 +928,19 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
? '当前为“跨库迁移”模式:适合将表迁移到另一数据源,可自动建表并导入数据。'
: '当前为“数据同步”模式:适合目标表已存在时做增量同步或覆盖导入。'}
/>
{isSourceQueryMode && (
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="SQL 结果集同步当前只支持:源端自定义 SQL -> 单个已存在目标表;查询结果需包含目标表主键列。"
/>
)}
<Form.Item label={isMigrationWorkflow ? '迁移内容' : '同步内容'}>
<Select value={syncContent} onChange={setSyncContent}>
<Option value="data"></Option>
<Option value="schema"></Option>
<Option value="both"> + </Option>
<Option value="schema" disabled={isSourceQueryMode}></Option>
<Option value="both" disabled={isSourceQueryMode}> + </Option>
</Select>
</Form.Item>
<Form.Item label={isMigrationWorkflow ? '迁移模式' : '同步模式'}>
@@ -863,7 +951,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
</Select>
</Form.Item>
<Form.Item label={isMigrationWorkflow ? '目标表处理策略' : '目标表要求'}>
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!isMigrationWorkflow}>
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!isMigrationWorkflow || isSourceQueryMode}>
<Option value="existing_only">使</Option>
<Option value="auto_create_if_missing"></Option>
<Option value="smart"></Option>
@@ -886,12 +974,12 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
</Form.Item>
)}
<Form.Item>
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)}>
MySQL MySQL Kingbase
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)} disabled={isSourceQueryMode}>
MySQL MySQL KingbaseSQL
</Checkbox>
</Form.Item>
<Form.Item>
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only'}>
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only' || isSourceQueryMode}>
/
</Checkbox>
</Form.Item>
@@ -927,21 +1015,56 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
{currentStep === 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={quietPanelStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<Text type="secondary"></Text>
<Checkbox checked={showSameTables} onChange={(e) => setShowSameTables(e.target.checked)}>
</Checkbox>
</div>
<Transfer
dataSource={allTables.map(t => ({ key: t, title: t }))}
titles={['源表', '已选表']}
targetKeys={selectedTables}
onChange={(keys) => setSelectedTables(keys as string[])}
render={item => item.title}
listStyle={{ width: 390, height: 320, marginTop: 0, borderRadius: 14, overflow: 'hidden' }}
locale={{ itemUnit: '项', itemsUnit: '项', searchPlaceholder: '搜索表…', notFoundContent: '暂无数据' }}
/>
{!isSourceQueryMode && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<Text type="secondary"></Text>
<Checkbox checked={showSameTables} onChange={(e) => setShowSameTables(e.target.checked)}>
</Checkbox>
</div>
<Transfer
dataSource={allTables.map(t => ({ key: t, title: t }))}
titles={['源表', '已选表']}
targetKeys={selectedTables}
onChange={(keys) => setSelectedTables(keys as string[])}
render={item => item.title}
listStyle={{ width: 390, height: 320, marginTop: 0, borderRadius: 14, overflow: 'hidden' }}
locale={{ itemUnit: '项', itemsUnit: '项', searchPlaceholder: '搜索表…', notFoundContent: '暂无数据' }}
/>
</>
)}
{isSourceQueryMode && (
<Form layout="vertical">
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="请输入源查询 SQL并选择一个目标表。差异分析会直接基于该结果集与目标表对比。"
/>
<Form.Item label="源查询 SQL">
<TextArea
value={sourceQuery}
onChange={(e) => setSourceQuery(e.target.value)}
rows={8}
placeholder="例如SELECT id, name, email FROM users WHERE status = 'active'"
spellCheck={false}
/>
</Form.Item>
<Form.Item label="目标表">
<Select
value={selectedTables[0]}
onChange={(value) => setSelectedTables(value ? [value] : [])}
showSearch
allowClear
placeholder="请选择一个目标表"
optionFilterProp="children"
>
{allTables.map((table) => <Option key={table} value={table}>{table}</Option>)}
</Select>
</Form.Item>
</Form>
)}
</div>
{diffTables.length > 0 && (
@@ -1060,8 +1183,9 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
render: (_: any, r: any) => {
const can = !!r.canSync;
const hasDiff = Number(r.inserts || 0) + Number(r.updates || 0) + Number(r.deletes || 0) > 0;
const hasSchemaDiff = Number(r.schemaDiffCount || 0) > 0;
return (
<Button size="small" disabled={!can || !hasDiff || analyzing} onClick={() => openPreview(r.table)}>
<Button size="small" disabled={!can || !(hasDiff || hasSchemaDiff) || analyzing} onClick={() => openPreview(r.table)}>
</Button>
);
@@ -1133,14 +1257,14 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
{currentStep === 1 && (
<>
<Button onClick={() => setCurrentStep(0)} style={{ marginRight: 8 }}></Button>
<Button onClick={analyzeDiff} loading={loading} disabled={syncContent === 'schema' || selectedTables.length === 0 || analyzing} style={{ marginRight: 8 }}>
<Button onClick={analyzeDiff} loading={loading} disabled={syncContent === 'schema' || selectedTables.length === 0 || analyzing || (isSourceQueryMode && !sourceQuery.trim())} style={{ marginRight: 8 }}>
</Button>
<Button
type="primary"
onClick={runSync}
loading={loading}
disabled={selectedTables.length === 0 || (syncContent !== 'schema' && diffTables.length === 0)}
disabled={selectedTables.length === 0 || (isSourceQueryMode && !sourceQuery.trim()) || (syncContent !== 'schema' && diffTables.length === 0)}
>
</Button>
@@ -1168,12 +1292,59 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
<Alert
type="info"
showIcon
message={`插入 ${previewData.totalInserts || 0},更新 ${previewData.totalUpdates || 0},删除 ${previewData.totalDeletes || 0}(预览最多展示 200 条/类型)`}
message={
previewHasDataDiff
? `插入 ${previewData.totalInserts || 0},更新 ${previewData.totalUpdates || 0},删除 ${previewData.totalDeletes || 0}(预览最多展示 200 条/类型)`
: (previewData.schemaSummary || `检测到 ${previewSql.statementCount} 条结构变更语句`)
}
/>
{previewSchemaWarnings.length > 0 && (
<Alert
style={{ marginTop: 12 }}
type="warning"
showIcon
message="结构预览包含风险或降级项"
description={
<ul style={{ margin: 0, paddingLeft: 18 }}>
{previewSchemaWarnings.slice(0, 8).map((item) => <li key={item}>{item}</li>)}
{previewSchemaWarnings.length > 8 && <li> {previewSchemaWarnings.length - 8} </li>}
</ul>
}
/>
)}
<Divider />
<Tabs
items={[
{
...(previewHasSchemaStatements ? [{
key: 'schema',
label: `结构(${Array.isArray(previewData.schemaStatements) ? previewData.schemaStatements.length : 0})`,
children: (
<div>
<Text type="secondary">
{previewData.schemaSummary || '以下为本次结构同步计划执行的语句。'}
</Text>
<pre
style={{
marginTop: 8,
marginBottom: 0,
padding: 10,
border: '1px solid #f0f0f0',
borderRadius: 6,
background: '#fafafa',
maxHeight: 420,
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}
>
{Array.isArray(previewData.schemaStatements) && previewData.schemaStatements.length > 0
? previewData.schemaStatements.join('\n')
: '-- 当前表结构无可执行变更'}
</pre>
</div>
)
}] : []),
...(previewHasDataDiff ? [{
key: 'insert',
label: `插入(${previewData.totalInserts || 0})`,
children: (
@@ -1273,7 +1444,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
/>
</div>
)
},
}] : []),
{
key: 'sql',
label: `SQL(${previewSql.statementCount})`,
@@ -1282,10 +1453,18 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
<Alert
type="info"
showIcon
message="SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。"
message={
previewHasDataDiff
? "SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。"
: "SQL 预览展示将执行的结构变更语句,用于审核确认。"
}
/>
<div style={{ marginTop: 8, marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text type="secondary"> {previewSql.statementCount} 200 /</Text>
<Text type="secondary">
{previewHasDataDiff
? `${previewSql.statementCount} 条语句(预览数据最多 200 条/类型)`
: `${previewSql.statementCount} 条结构变更语句`}
</Text>
<Button
size="small"
disabled={!previewSql.sqlText}
@@ -1314,7 +1493,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
wordBreak: 'break-word'
}}
>
{previewSql.sqlText || '-- 当前勾选范围下无 SQL 可预览'}
{previewSql.sqlText || (previewHasDataDiff ? '-- 当前勾选范围下无 SQL 可预览' : '-- 当前表结构无可执行变更')}
</pre>
</div>
)

View File

@@ -0,0 +1,199 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TabData } from '../types';
import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
import DataViewer from './DataViewer';
const storeState = vi.hoisted(() => ({
connections: [
{
id: 'conn-1',
name: 'oracle',
config: {
type: 'oracle',
host: '127.0.0.1',
port: 1521,
user: 'scott',
password: '',
database: 'ORCLPDB1',
},
},
],
addSqlLog: vi.fn(),
}));
const backendApp = vi.hoisted(() => ({
DBQuery: vi.fn(),
DBGetColumns: vi.fn(),
DBGetIndexes: vi.fn(),
}));
const messageApi = vi.hoisted(() => ({
error: vi.fn(),
warning: vi.fn(),
}));
const dataGridState = vi.hoisted(() => ({
latestProps: null as any,
}));
vi.mock('../store', () => {
const useStore = Object.assign(
(selector: (state: typeof storeState) => any) => selector(storeState),
{ getState: () => storeState },
);
return { useStore };
});
vi.mock('../../wailsjs/go/app/App', () => backendApp);
vi.mock('antd', () => ({
message: messageApi,
}));
vi.mock('./DataGrid', () => ({
default: (props: any) => {
dataGridState.latestProps = props;
return <div data-grid="true" />;
},
GONAVI_ROW_KEY: '__gonavi_row_key__',
}));
const createTab = (overrides: Partial<TabData> = {}): TabData => ({
id: 'tab-1',
title: 'EDC_LOG',
type: 'table',
connectionId: 'conn-1',
dbName: 'MYCIMLED',
tableName: 'EDC_LOG',
...overrides,
});
const flushPromises = async () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
};
describe('DataViewer safe editing locator', () => {
const renderAndReload = async (tab: TabData = createTab()) => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<DataViewer tab={tab} />);
});
await act(async () => {
await dataGridState.latestProps.onReload();
});
await flushPromises();
return renderer!;
};
beforeEach(() => {
vi.clearAllMocks();
dataGridState.latestProps = null;
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQuery.mockResolvedValue({
success: true,
fields: ['ID', 'NAME'],
data: [{ ID: 7, NAME: 'old-name' }],
});
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
});
it('enables table preview editing after primary keys are loaded', async () => {
backendApp.DBGetColumns.mockResolvedValue({
success: true,
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
});
const renderer = await renderAndReload();
expect(dataGridState.latestProps?.pkColumns).toEqual(['ID']);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'primary-key',
columns: ['ID'],
valueColumns: ['ID'],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(messageApi.warning).not.toHaveBeenCalled();
renderer.unmount();
});
it('uses a unique index when the table has no primary key', async () => {
backendApp.DBGetColumns.mockResolvedValue({
success: true,
data: [{ name: 'EMAIL', key: '' }, { name: 'NAME', key: '' }],
});
backendApp.DBGetIndexes.mockResolvedValue({
success: true,
data: [{ name: 'UK_EMAIL', columnName: 'EMAIL', nonUnique: 0, seqInIndex: 1, indexType: 'BTREE' }],
});
const renderer = await renderAndReload();
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'unique-key',
columns: ['EMAIL'],
valueColumns: ['EMAIL'],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(messageApi.warning).not.toHaveBeenCalled();
renderer.unmount();
});
it('uses hidden Oracle ROWID when no primary or unique key is available', async () => {
backendApp.DBGetColumns.mockResolvedValue({
success: true,
data: [{ name: 'ID', key: '' }, { name: 'NAME', key: '' }],
});
backendApp.DBQuery.mockResolvedValue({
success: true,
fields: ['ID', 'NAME', ORACLE_ROWID_LOCATOR_COLUMN],
data: [{ ID: 7, NAME: 'old-name', [ORACLE_ROWID_LOCATOR_COLUMN]: 'AAAA' }],
});
const renderer = await renderAndReload();
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'oracle-rowid',
columns: ['ROWID'],
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(messageApi.warning).not.toHaveBeenCalled();
expect(backendApp.DBQuery.mock.calls.some((call: any[]) => String(call[2]).includes(`ROWID AS "${ORACLE_ROWID_LOCATOR_COLUMN}"`))).toBe(true);
renderer.unmount();
});
it('keeps non-Oracle table preview read-only when no safe locator exists', async () => {
storeState.connections[0].config.type = 'mysql';
storeState.connections[0].config.database = 'main';
backendApp.DBGetColumns.mockResolvedValue({
success: true,
data: [{ name: 'ID', key: '' }, { name: 'NAME', key: '' }],
});
const renderer = await renderAndReload(createTab({ dbName: 'main', tableName: 'users', title: 'users' }));
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'none',
readOnly: true,
reason: '未检测到主键或可用唯一索引,无法安全提交修改。',
});
expect(dataGridState.latestProps?.readOnly).toBe(true);
expect(messageApi.warning).toHaveBeenCalledWith('表 main.users 保持只读:未检测到主键或可用唯一索引,无法安全提交修改。');
renderer.unmount();
});
});

View File

@@ -1,15 +1,26 @@
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { message } from 'antd';
import { TabData, ColumnDefinition } from '../types';
import { TabData, ColumnDefinition, IndexDefinition } from '../types';
import { useStore } from '../store';
import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
import { DBQuery, DBGetColumns, DBGetIndexes } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
import { buildMongoCountCommand, buildMongoFilter, buildMongoFindCommand, buildMongoSort } from '../utils/mongodb';
import { buildOracleApproximateTotalSql, parseApproximateTableCountRow, resolveApproximateTableCountStrategy } from '../utils/approximateTableCount';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities';
import { resolveDataViewerAutoFetchAction } from '../utils/dataViewerAutoFetch';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import {
buildEffectiveFilterConditions,
normalizeQuickWhereCondition,
validateQuickWhereCondition,
} from '../utils/dataGridWhereFilter';
import {
ORACLE_ROWID_LOCATOR_COLUMN,
resolveEditRowLocator,
type EditRowLocator,
} from '../utils/rowLocator';
import { isOracleLikeDialect } from '../utils/sqlDialect';
type ViewerPaginationState = {
current: number;
@@ -74,6 +85,47 @@ const parseTotalFromCountRow = (row: any): number | null => {
return null;
};
const buildDataViewerReadOnlyLocator = (reason: string): EditRowLocator => ({
strategy: 'none',
columns: [],
valueColumns: [],
readOnly: true,
reason,
});
const formatDataViewerTableName = (dbName: string, tableName: string): string => (
dbName ? `${dbName}.${tableName}` : tableName
);
const getTableColumnNames = (columns: ColumnDefinition[] | undefined): string[] => (
(columns || [])
.map((column) => String(column?.name || '').trim())
.filter(Boolean)
);
const resolveDataViewerOrderFallbackColumns = (locator: EditRowLocator | undefined, pkColumns: string[]): string[] => {
if (locator && !locator.readOnly && locator.strategy !== 'oracle-rowid') {
return locator.valueColumns.length > 0 ? locator.valueColumns : locator.columns;
}
return pkColumns;
};
const buildDataViewerBaseSelectSQL = (
dbType: string,
tableName: string,
whereSQL: string,
locator?: EditRowLocator,
): string => {
const quotedTableName = quoteQualifiedIdent(dbType, tableName);
if (locator?.strategy !== 'oracle-rowid') {
return `SELECT * FROM ${quotedTableName} ${whereSQL}`;
}
const alias = 'gonavi_row_source';
const rowIDAlias = quoteIdentPart(dbType, ORACLE_ROWID_LOCATOR_COLUMN);
return `SELECT ${alias}.*, ${alias}.ROWID AS ${rowIDAlias} FROM ${quotedTableName} ${alias} ${whereSQL}`;
};
const normalizeDuckDBIdentifier = (raw: string): string => {
const text = String(raw || '').trim();
if (text.length >= 2) {
@@ -135,6 +187,7 @@ const reverseOrderBySQL = (orderBySQL: string): string => {
type ViewerFilterSnapshot = {
showFilter: boolean;
conditions: FilterCondition[];
quickWhereCondition: string;
currentPage: number;
pageSize: number;
sortInfo: Array<{ columnKey: string, order: string, enabled?: boolean }>;
@@ -165,11 +218,12 @@ const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefin
const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => {
const cached = viewerFilterSnapshotsByTab.get(String(tabId || '').trim());
if (!cached) {
return { showFilter: false, conditions: [], currentPage: 1, pageSize: 100, sortInfo: [], scrollTop: 0, scrollLeft: 0 };
return { showFilter: false, conditions: [], quickWhereCondition: '', currentPage: 1, pageSize: 100, sortInfo: [], scrollTop: 0, scrollLeft: 0 };
}
return {
showFilter: cached.showFilter === true,
conditions: normalizeViewerFilterConditions(cached.conditions),
quickWhereCondition: normalizeQuickWhereCondition(cached.quickWhereCondition),
currentPage: Number.isFinite(Number(cached.currentPage)) && Number(cached.currentPage) > 0 ? Number(cached.currentPage) : 1,
pageSize: Number.isFinite(Number(cached.pageSize)) && Number(cached.pageSize) > 0 ? Number(cached.pageSize) : 100,
sortInfo: Array.isArray(cached.sortInfo)
@@ -186,6 +240,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const [data, setData] = useState<any[]>([]);
const [columnNames, setColumnNames] = useState<string[]>([]);
const [pkColumns, setPkColumns] = useState<string[]>([]);
const [editLocator, setEditLocator] = useState<EditRowLocator | undefined>(undefined);
const [loading, setLoading] = useState(false);
const connections = useStore(state => state.connections);
const addSqlLog = useStore(state => state.addSqlLog);
@@ -226,6 +281,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const [showFilter, setShowFilter] = useState<boolean>(initialViewerSnapshot.showFilter);
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>(initialViewerSnapshot.conditions);
const [quickWhereCondition, setQuickWhereCondition] = useState<string>(initialViewerSnapshot.quickWhereCondition);
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config;
const currentConnCaps = getDataSourceCapabilities(currentConnConfig);
@@ -239,6 +295,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
viewerFilterSnapshotsByTab.set(normalizedTabId, {
showFilter,
conditions: normalizeViewerFilterConditions(filterConditions),
quickWhereCondition: normalizeQuickWhereCondition(quickWhereCondition),
currentPage: pagination.current,
pageSize: pagination.pageSize,
sortInfo,
@@ -246,12 +303,13 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
scrollLeft: scrollSnapshotRef.current.left,
...overrides,
});
}, [showFilter, filterConditions, pagination.current, pagination.pageSize, sortInfo]);
}, [showFilter, filterConditions, quickWhereCondition, pagination.current, pagination.pageSize, sortInfo]);
useEffect(() => {
const snapshot = getViewerFilterSnapshot(tab.id);
setShowFilter(snapshot.showFilter);
setFilterConditions(snapshot.conditions);
setQuickWhereCondition(snapshot.quickWhereCondition);
setSortInfo(snapshot.sortInfo);
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
initialLoadRef.current = false;
@@ -259,7 +317,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
useEffect(() => {
persistViewerSnapshot(tab.id);
}, [tab.id, persistViewerSnapshot]);
}, [persistViewerSnapshot]);
useEffect(() => {
return () => {
@@ -270,6 +328,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
useEffect(() => {
const snapshot = getViewerFilterSnapshot(tab.id);
setPkColumns([]);
setEditLocator(undefined);
pkKeyRef.current = '';
countKeyRef.current = '';
duckdbApproxKeyRef.current = '';
@@ -396,9 +455,17 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const dbType = config.type || '';
const dbType = resolveDataSourceType(config);
const dbTypeLower = String(dbType || '').trim().toLowerCase();
const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros';
const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros';
const normalizedQuickWhereCondition = normalizeQuickWhereCondition(quickWhereCondition);
const quickWhereValidation = validateQuickWhereCondition(normalizedQuickWhereCondition);
if (!quickWhereValidation.ok) {
message.error(quickWhereValidation.message);
if (fetchSeqRef.current === seq) setLoading(false);
return;
}
const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, normalizedQuickWhereCondition);
const dbName = tab.dbName || '';
const tableName = tab.tableName || '';
@@ -406,7 +473,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
let mongoFilter: Record<string, unknown> | undefined;
if (isMongoDB) {
try {
mongoFilter = buildMongoFilter(filterConditions);
mongoFilter = buildMongoFilter(effectiveFilterConditions);
} catch (e: any) {
message.error(`Mongo 筛选条件无效:${String(e?.message || e || '解析失败')}`);
if (fetchSeqRef.current === seq) setLoading(false);
@@ -416,11 +483,85 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const whereSQL = isMongoDB
? JSON.stringify(mongoFilter || {})
: buildWhereSQL(dbType, filterConditions);
: buildWhereSQL(dbType, effectiveFilterConditions);
let pkColumnsForQuery = pkColumns;
let editLocatorForQuery = editLocator;
if (!isMongoDB && !forceReadOnly && tableName) {
const locatorKey = `${tab.connectionId}|${dbTypeLower}|${dbName}|${tableName}`;
if (pkKeyRef.current !== locatorKey || !editLocatorForQuery) {
pkKeyRef.current = locatorKey;
const locatorSeq = ++pkSeqRef.current;
try {
const [resCols, resIndexes] = await Promise.all([
DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableName),
DBGetIndexes(buildRpcConnectionConfig(config) as any, dbName, tableName)
.catch((error: any) => ({ success: false, message: String(error?.message || error || '加载索引失败'), data: [] })),
]);
if (fetchSeqRef.current !== seq) return;
if (pkSeqRef.current !== locatorSeq) return;
if (pkKeyRef.current !== locatorKey) return;
if (!resCols?.success || !Array.isArray(resCols.data)) {
const nextLocator = buildDataViewerReadOnlyLocator('无法加载主键/唯一索引元数据,无法安全提交修改。');
pkColumnsForQuery = [];
editLocatorForQuery = nextLocator;
setPkColumns([]);
setEditLocator(nextLocator);
message.warning(`${formatDataViewerTableName(dbName, tableName)} 保持只读:${nextLocator.reason}`);
} else {
const columnDefs = resCols.data as ColumnDefinition[];
const primaryKeys = columnDefs
.filter((column: any) => column?.key === 'PRI')
.map((column: any) => String(column?.name || '').trim())
.filter(Boolean);
const indexes = resIndexes?.success && Array.isArray(resIndexes.data)
? resIndexes.data as IndexDefinition[]
: [];
const resultColumns = getTableColumnNames(columnDefs);
const locatorColumns = isOracleLikeDialect(dbType)
? [...resultColumns, ORACLE_ROWID_LOCATOR_COLUMN]
: resultColumns;
let nextLocator = resolveEditRowLocator({
dbType,
resultColumns: locatorColumns,
primaryKeys,
indexes,
allowOracleRowID: true,
});
if (nextLocator.readOnly && primaryKeys.length === 0 && !resIndexes?.success && !isOracleLikeDialect(dbType)) {
nextLocator = buildDataViewerReadOnlyLocator('无法加载唯一索引元数据,无法安全提交修改。');
}
pkColumnsForQuery = primaryKeys;
editLocatorForQuery = nextLocator;
setPkColumns(primaryKeys);
setEditLocator(nextLocator);
if (nextLocator.readOnly) {
message.warning(`${formatDataViewerTableName(dbName, tableName)} 保持只读:${nextLocator.reason || '当前结果没有可用的安全行定位方式,无法提交修改。'}`);
}
}
} catch {
if (fetchSeqRef.current !== seq) return;
if (pkSeqRef.current !== locatorSeq) return;
if (pkKeyRef.current !== locatorKey) return;
const nextLocator = buildDataViewerReadOnlyLocator('无法加载主键/唯一索引元数据,无法安全提交修改。');
pkColumnsForQuery = [];
editLocatorForQuery = nextLocator;
setPkColumns([]);
setEditLocator(nextLocator);
message.warning(`${formatDataViewerTableName(dbName, tableName)} 保持只读:${nextLocator.reason}`);
}
}
}
const countSql = isMongoDB
? buildMongoCountCommand(tableName, mongoFilter || {})
: `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
const orderBySQL = isMongoDB ? '' : buildOrderBySQL(dbType, sortInfo, pkColumns);
const orderBySQL = isMongoDB
? ''
: buildOrderBySQL(dbType, sortInfo, resolveDataViewerOrderFallbackColumns(editLocatorForQuery, pkColumnsForQuery));
const totalRows = Number(pagination.total);
const hasFiniteTotal = Number.isFinite(totalRows) && totalRows >= 0;
const totalKnown = pagination.totalKnown && hasFiniteTotal;
@@ -451,7 +592,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
skip: offset,
});
} else {
const baseSql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
const baseSql = buildDataViewerBaseSelectSQL(dbType, tableName, whereSQL, editLocatorForQuery);
sql = `${baseSql}${orderBySQL}`;
// ClickHouse 深分页在超大 OFFSET 下容易超时。对于总数已知且存在 ORDER BY 的场景,
// 当“尾部偏移”小于“头部偏移”时,改为反向 ORDER BY + 小 OFFSET并在前端翻转结果。
@@ -539,7 +680,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
if (safeSelect) {
let fallbackSql = `SELECT ${safeSelect} FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
fallbackSql = buildPaginatedSelectSQL(dbType, fallbackSql, buildOrderBySQL(dbType, sortInfo, pkColumns), size + 1, offset);
fallbackSql = buildPaginatedSelectSQL(dbType, fallbackSql, buildOrderBySQL(dbType, sortInfo, resolveDataViewerOrderFallbackColumns(editLocatorForQuery, pkColumnsForQuery)), size + 1, offset);
executedSql = fallbackSql;
resData = await executeDataQuery(fallbackSql, '复杂类型降级重试');
}
@@ -562,26 +703,6 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
message.warning('已自动提升排序缓冲并重试成功。');
}
}
if (pkColumns.length === 0) {
const pkKey = `${tab.connectionId}|${dbName}|${tableName}`;
if (pkKeyRef.current !== pkKey) {
pkKeyRef.current = pkKey;
const pkSeq = ++pkSeqRef.current;
DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableName)
.then((resCols: any) => {
if (pkSeqRef.current !== pkSeq) return;
if (pkKeyRef.current !== pkKey) return;
if (!resCols?.success) return;
const pks = (resCols.data as ColumnDefinition[]).filter((c: any) => c.key === 'PRI').map((c: any) => c.name);
setPkColumns(pks);
})
.catch(() => {
if (pkSeqRef.current !== pkSeq) return;
if (pkKeyRef.current !== pkKey) return;
});
}
}
if (resData.success) {
let resultData = resData.data as any[];
@@ -824,9 +945,9 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
});
}
if (fetchSeqRef.current === seq) setLoading(false);
}, [connections, tab, sortInfo, filterConditions, pkColumns, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]);
// 依赖 pkColumns:在无手动排序时可回退到主键稳定排序。
// 主键信息只会在首次加载后更新一次,避免循环查询。
}, [connections, tab, sortInfo, filterConditions, quickWhereCondition, pkColumns, editLocator, forceReadOnly, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]);
// 依赖定位列:在无手动排序时可回退到安全定位列稳定排序。
// 定位信息只会在表上下文变化后重新加载,避免循环查询。
// Handlers memoized
const handleReload = useCallback(() => {
@@ -852,24 +973,34 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]);
const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []);
const handleApplyFilter = useCallback((conditions: FilterCondition[]) => setFilterConditions(conditions), []);
const handleApplyQuickWhereCondition = useCallback((condition: string) => {
const normalized = normalizeQuickWhereCondition(condition);
const validation = validateQuickWhereCondition(normalized);
if (!validation.ok) {
message.error(validation.message);
return;
}
setQuickWhereCondition(normalized);
}, []);
const exportSqlWithFilter = useMemo(() => {
const tableName = String(tab.tableName || '').trim();
const dbType = String(currentConnConfig?.type || '').trim();
const dbType = resolveDataSourceType(currentConnConfig);
if (!tableName || !dbType) return '';
const whereSQL = buildWhereSQL(dbType, filterConditions);
const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, quickWhereCondition);
const whereSQL = buildWhereSQL(dbType, effectiveFilterConditions);
if (!whereSQL) return '';
let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
sql += buildOrderBySQL(dbType, sortInfo, pkColumns);
sql += buildOrderBySQL(dbType, sortInfo, resolveDataViewerOrderFallbackColumns(editLocator, pkColumns));
const normalizedType = dbType.toLowerCase();
const hasSortForBuffer = hasExplicitSort(sortInfo);
if (hasSortForBuffer && (normalizedType === 'mysql' || normalizedType === 'mariadb')) {
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
}
return sql;
}, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]);
}, [tab.tableName, currentConnConfig?.type, currentConnConfig?.driver, filterConditions, quickWhereCondition, sortInfo, editLocator, pkColumns]);
useEffect(() => {
const action = resolveDataViewerAutoFetchAction({
@@ -886,7 +1017,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
return;
}
fetchData(1, pagination.pageSize);
}, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
}, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions, quickWhereCondition]); // Initial load and re-load on sort/filter
return (
<div style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
@@ -899,6 +1030,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
dbName={tab.dbName}
connectionId={tab.connectionId}
pkColumns={pkColumns}
editLocator={editLocator}
onReload={handleReload}
onSort={handleSort}
onPageChange={handlePageChange}
@@ -909,7 +1041,9 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
onToggleFilter={handleToggleFilter}
onApplyFilter={handleApplyFilter}
appliedFilterConditions={filterConditions}
readOnly={forceReadOnly}
quickWhereCondition={quickWhereCondition}
onApplyQuickWhereCondition={handleApplyQuickWhereCondition}
readOnly={forceReadOnly || !editLocator || editLocator.readOnly}
sortInfoExternal={sortInfo}
exportSqlWithFilter={exportSqlWithFilter || undefined}
scrollSnapshot={scrollSnapshotRef.current}

View File

@@ -12,9 +12,11 @@ export interface DbIconProps {
const DB_DEFAULT_COLORS: Record<string, string> = {
mysql: '#00758F',
mariadb: '#003545',
oceanbase: '#0052CC',
postgres: '#336791',
redis: '#DC382D',
mongodb: '#47A248',
jvm: '#1677FF',
kingbase: '#1890FF',
dameng: '#E6002D',
oracle: '#F80000',
@@ -23,6 +25,7 @@ const DB_DEFAULT_COLORS: Record<string, string> = {
sqlite: '#003B57',
duckdb: '#FFC107',
vastbase: '#0066CC',
opengauss: '#2446A8',
highgo: '#00A86B',
tdengine: '#2962FF',
diros: '#0050B3',
@@ -89,6 +92,9 @@ const MySQLIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
const MariaDBIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="mariadb" size={size} color={color} />
);
const OceanBaseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.oceanbase} label="OB" />
);
const PostgresIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="postgres" size={size} color={color} />
);
@@ -130,12 +136,18 @@ const DamengIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
const VastBaseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.vastbase} label="VB" />
);
const OpenGaussIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.opengauss} label="OG" />
);
const HighGoIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.highgo} label="HG" />
);
const TDengineIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.tdengine} label="TD" />
);
const JVMIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.jvm} label="JVM" />
);
/** Custom — 齿轮图标 */
const CustomIcon: React.FC<DbIconProps> = ({ size = 16, color }) => {
@@ -161,11 +173,13 @@ const SphinxIconFallback: React.FC<DbIconProps> = ({ size = 16, color }) => (
const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
mysql: MySQLIcon,
mariadb: MariaDBIcon,
oceanbase: OceanBaseIcon,
diros: DorisIcon,
sphinx: SphinxIcon,
postgres: PostgresIcon,
redis: RedisIcon,
mongodb: MongoDBIcon,
jvm: JVMIcon,
kingbase: KingBaseIcon,
dameng: DamengIcon,
oracle: OracleIcon,
@@ -174,6 +188,7 @@ const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
sqlite: SQLiteIcon,
duckdb: DuckDBIcon,
vastbase: VastBaseIcon,
opengauss: OpenGaussIcon,
highgo: HighGoIcon,
tdengine: TDengineIcon,
custom: CustomIcon,
@@ -181,9 +196,9 @@ const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
/** 可选图标类型列表(用于图标选择器 UI */
export const DB_ICON_TYPES: string[] = [
'mysql', 'mariadb', 'postgres', 'redis', 'mongodb',
'mysql', 'mariadb', 'oceanbase', 'postgres', 'redis', 'mongodb', 'jvm',
'oracle', 'sqlserver', 'sqlite', 'duckdb', 'clickhouse',
'kingbase', 'dameng', 'vastbase', 'highgo', 'tdengine', 'custom',
'kingbase', 'dameng', 'vastbase', 'opengauss', 'highgo', 'tdengine', 'custom',
];
/** 该类型是否有品牌 SVG 文件 */
@@ -199,11 +214,12 @@ export const getDbIcon = (type: string, color?: string, size?: number): React.Re
/** 获取数据库图标显示名称(中文) */
export const getDbIconLabel = (type: string): string => {
const labels: Record<string, string> = {
mysql: 'MySQL', mariadb: 'MariaDB', postgres: 'PostgreSQL',
redis: 'Redis', mongodb: 'MongoDB', oracle: 'Oracle',
mysql: 'MySQL', mariadb: 'MariaDB', oceanbase: 'OceanBase', postgres: 'PostgreSQL',
redis: 'Redis', mongodb: 'MongoDB', jvm: 'JVM',
oracle: 'Oracle',
sqlserver: 'SQL Server', clickhouse: 'ClickHouse', sqlite: 'SQLite',
duckdb: 'DuckDB', kingbase: '金仓', dameng: '达梦',
vastbase: 'VastBase', highgo: '瀚高', tdengine: 'TDengine',
vastbase: 'VastBase', opengauss: 'OpenGauss', highgo: '瀚高', tdengine: 'TDengine',
custom: '自定义',
};
return labels[type?.toLowerCase()] || type;

View File

@@ -10,6 +10,23 @@ interface DefinitionViewerProps {
tab: TabData;
}
const normalizeMySQLViewDDL = (rawDefinition: unknown): string => {
const text = String(rawDefinition || '').trim();
if (!text) return '';
const normalized = text.replace(/\r\n/g, '\n').trim().replace(/;+\s*$/, '');
const createViewPrefixPattern = /^\s*create\s+(?:algorithm\s*=\s*\w+\s+)?(?:definer\s*=\s*(?:`[^`]+`|\S+)\s*@\s*(?:`[^`]+`|\S+)\s+)?(?:sql\s+security\s+(?:definer|invoker)\s+)?view\s+/i;
if (createViewPrefixPattern.test(normalized)) {
return `${normalized.replace(createViewPrefixPattern, 'CREATE OR REPLACE VIEW ')};`;
}
if (/^\s*(select|with)\b/i.test(normalized)) {
return normalized;
}
return `${normalized};`;
};
const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -26,9 +43,12 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
if (type === 'custom') {
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
if (driver === 'diros' || driver === 'doris') return 'mysql';
if (driver === 'oceanbase') return 'mysql';
if (driver === 'opengauss' || driver === 'open_gauss' || driver === 'open-gauss') return 'opengauss';
return driver;
}
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'oceanbase' && String(conn?.config?.oceanBaseProtocol || '').trim().toLowerCase() === 'oracle') return 'oracle';
if (type === 'mariadb' || type === 'oceanbase' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
@@ -116,7 +136,8 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase': {
case 'vastbase':
case 'opengauss': {
const schemaRef = schema || 'public';
return [`SELECT pg_get_viewdef('${escapeSQLLiteral(schemaRef)}.${safeName}'::regclass, true) AS view_definition`];
}
@@ -162,7 +183,8 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase': {
case 'vastbase':
case 'opengauss': {
const schemaRef = schema || 'public';
return [`SELECT pg_get_functiondef(p.oid) AS routine_definition FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname = '${escapeSQLLiteral(schemaRef)}' AND p.proname = '${safeName}' LIMIT 1`];
}
@@ -257,15 +279,15 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
case 'mysql': {
const keys = Object.keys(row);
const textDefinition = row.view_definition || row.VIEW_DEFINITION;
if (textDefinition) return String(textDefinition);
if (textDefinition) return normalizeMySQLViewDDL(textDefinition);
const sqlKey = keys.find(k => k.toLowerCase().includes('create view') || k.toLowerCase() === 'create view');
if (sqlKey) return row[sqlKey];
if (sqlKey) return normalizeMySQLViewDDL(row[sqlKey]);
const tableSqlKey = keys.find(k => k.toLowerCase().includes('create table'));
if (tableSqlKey) return row[tableSqlKey];
if (tableSqlKey) return normalizeMySQLViewDDL(row[tableSqlKey]);
for (const key of keys) {
const val = String(row[key] || '');
if (val.toUpperCase().includes('CREATE') && (val.toUpperCase().includes('VIEW') || val.toUpperCase().includes('TABLE'))) {
return val;
return normalizeMySQLViewDDL(val);
}
}
return JSON.stringify(row, null, 2);

View File

@@ -1,9 +1,14 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Button, Collapse, Input, Modal, Progress, Select, Space, Switch, Table, Tag, Typography, message } from 'antd';
import { Alert, Button, Collapse, Empty, Input, Modal, Progress, Select, Space, Switch, Tag, Typography, message } from 'antd';
import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, FolderOpenOutlined, InfoCircleFilled, ReloadOutlined } from '@ant-design/icons';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { useStore } from '../store';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import {
DRIVER_LOCAL_IMPORT_BUTTON_LABEL,
DRIVER_LOCAL_IMPORT_DIRECTORY_HELP,
DRIVER_LOCAL_IMPORT_SINGLE_FILE_HELP,
} from '../utils/driverImportGuidance';
import {
CheckDriverNetworkStatus,
DownloadDriverPackage,
@@ -34,6 +39,11 @@ type DriverStatusRow = {
packagePath?: string;
executablePath?: string;
downloadedAt?: string;
agentRevision?: string;
expectedRevision?: string;
needsUpdate?: boolean;
updateReason?: string;
affectedConnections?: number;
message?: string;
};
@@ -103,7 +113,6 @@ type DriverVersionOption = {
const buildVersionOptionKey = (option: DriverVersionOption) => `${option.version}@@${option.downloadUrl}`;
const buildVersionSizeLoadingKey = (driverType: string, optionKey: string) => `${driverType}@@${optionKey}`;
const DRIVER_TABLE_SCROLL_X = 1450;
const DRIVER_STATUS_CACHE_TTL_MS = 60 * 1000;
const DRIVER_NETWORK_CACHE_TTL_MS = 5 * 60 * 1000;
const normalizeDriverSearchText = (value: string) => String(value || '').trim().toLowerCase();
@@ -169,11 +178,6 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
const darkMode = theme === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const modalContentRef = useRef<HTMLDivElement | null>(null);
const tableContainerRef = useRef<HTMLDivElement | null>(null);
const tableScrollTargetsRef = useRef<HTMLElement[]>([]);
const externalHScrollRef = useRef<HTMLDivElement | null>(null);
const horizontalSyncSourceRef = useRef<'table' | 'external' | ''>('');
const [loading, setLoading] = useState(false);
const [downloadDir, setDownloadDir] = useState('');
const [networkChecking, setNetworkChecking] = useState(false);
@@ -191,7 +195,6 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
const [selectedVersionMap, setSelectedVersionMap] = useState<Record<string, string>>({});
const [versionLoadingMap, setVersionLoadingMap] = useState<Record<string, boolean>>({});
const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState<Record<string, boolean>>({});
const [horizontalScrollWidth, setHorizontalScrollWidth] = useState(DRIVER_TABLE_SCROLL_X);
const downloadDirRef = useRef(downloadDir);
useEffect(() => {
@@ -244,76 +247,6 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
});
}, []);
const refreshHorizontalScrollState = useCallback(() => {
const tableContainer = tableContainerRef.current;
const targets = tableContainer
? [
...new Set(
[
...Array.from(tableContainer.querySelectorAll('.ant-table-content')),
...Array.from(tableContainer.querySelectorAll('.ant-table-body')),
].filter((node): node is HTMLElement => node instanceof HTMLElement),
),
]
: tableScrollTargetsRef.current;
if (!targets || targets.length === 0) {
setHorizontalScrollWidth(DRIVER_TABLE_SCROLL_X);
return;
}
const nextWidth = Math.max(
DRIVER_TABLE_SCROLL_X,
...targets.map((target) => Math.max(0, target.scrollWidth)),
);
setHorizontalScrollWidth((prev) => (prev === nextWidth ? prev : nextWidth));
const externalScroll = externalHScrollRef.current;
if (!externalScroll || horizontalSyncSourceRef.current === 'external') {
return;
}
const preferredTarget =
targets.find((target) => target.scrollWidth > target.clientWidth + 1) ||
targets[0];
const targetScrollLeft = preferredTarget?.scrollLeft || 0;
if (Math.abs(externalScroll.scrollLeft - targetScrollLeft) > 1) {
externalScroll.scrollLeft = targetScrollLeft;
}
}, []);
const applyExternalScrollToTableTargets = useCallback(() => {
const tableContainer = tableContainerRef.current;
const externalScroll = externalHScrollRef.current;
if (!(tableContainer instanceof HTMLElement) || !(externalScroll instanceof HTMLDivElement)) {
return;
}
if (horizontalSyncSourceRef.current === 'table') {
return;
}
const liveTargets = [
...new Set(
[
...Array.from(tableContainer.querySelectorAll('.ant-table-content')),
...Array.from(tableContainer.querySelectorAll('.ant-table-body')),
].filter((node): node is HTMLElement => node instanceof HTMLElement),
),
];
if (liveTargets.length === 0) {
return;
}
horizontalSyncSourceRef.current = 'external';
liveTargets.forEach((target) => {
if (target.scrollWidth <= target.clientWidth + 1) {
return;
}
if (Math.abs(target.scrollLeft - externalScroll.scrollLeft) > 1) {
target.scrollLeft = externalScroll.scrollLeft;
}
});
horizontalSyncSourceRef.current = '';
}, []);
const refreshStatus = useCallback(async (
toastOnError = true,
options?: { showLoading?: boolean },
@@ -355,6 +288,13 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
packagePath: String(item.packagePath || '').trim() || undefined,
executablePath: String(item.executablePath || '').trim() || undefined,
downloadedAt: String(item.downloadedAt || '').trim() || undefined,
agentRevision: String(item.agentRevision || '').trim() || undefined,
expectedRevision: String(item.expectedRevision || '').trim() || undefined,
needsUpdate: !!item.needsUpdate,
updateReason: String(item.updateReason || '').trim() || undefined,
affectedConnections: Number.isFinite(Number(item.affectedConnections))
? Number(item.affectedConnections)
: undefined,
message: String(item.message || '').trim() || undefined,
}));
setRows(nextRows);
@@ -584,8 +524,6 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
useEffect(() => {
if (!open) {
setHorizontalScrollWidth(DRIVER_TABLE_SCROLL_X);
tableScrollTargetsRef.current = [];
return;
}
@@ -613,117 +551,6 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
}
}, [checkNetworkStatus, open, refreshStatus]);
useEffect(() => {
if (!open) {
return;
}
const tableContainer = tableContainerRef.current;
const externalScroll = externalHScrollRef.current;
if (!(tableContainer instanceof HTMLElement) || !(externalScroll instanceof HTMLDivElement)) {
return;
}
let currentTargets: HTMLElement[] = [];
let rafId: number | null = null;
let bodyResizeObserver: ResizeObserver | null = null;
let containerResizeObserver: ResizeObserver | null = null;
const pickSyncTarget = () => {
if (currentTargets.length === 0) {
return null;
}
return currentTargets.find((target) => target.scrollWidth > target.clientWidth + 1) || currentTargets[0];
};
const syncFromTableTarget = (event?: Event) => {
const source = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null;
const activeTarget = source || pickSyncTarget();
if (!activeTarget) {
return;
}
if (horizontalSyncSourceRef.current === 'external') {
return;
}
horizontalSyncSourceRef.current = 'table';
if (Math.abs(externalScroll.scrollLeft - activeTarget.scrollLeft) > 1) {
externalScroll.scrollLeft = activeTarget.scrollLeft;
}
horizontalSyncSourceRef.current = '';
};
const bindCurrentTableTargets = () => {
const nextTargets = [
...new Set(
[
...Array.from(tableContainer.querySelectorAll('.ant-table-content')),
...Array.from(tableContainer.querySelectorAll('.ant-table-body')),
].filter((node): node is HTMLElement => node instanceof HTMLElement),
),
];
const sameTargets =
nextTargets.length === currentTargets.length &&
nextTargets.every((target, index) => target === currentTargets[index]);
if (sameTargets) {
return;
}
currentTargets.forEach((target) => {
target.removeEventListener('scroll', syncFromTableTarget);
bodyResizeObserver?.unobserve(target);
});
currentTargets = nextTargets;
tableScrollTargetsRef.current = nextTargets;
currentTargets.forEach((target) => {
target.addEventListener('scroll', syncFromTableTarget, { passive: true });
bodyResizeObserver?.observe(target);
});
refreshHorizontalScrollState();
syncFromTableTarget();
};
const scheduleRefresh = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
bindCurrentTableTargets();
refreshHorizontalScrollState();
});
};
const mutationObserver = new MutationObserver(scheduleRefresh);
mutationObserver.observe(tableContainer, { childList: true, subtree: true });
bodyResizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(scheduleRefresh) : null;
containerResizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(scheduleRefresh) : null;
containerResizeObserver?.observe(tableContainer);
if (typeof ResizeObserver !== 'undefined') {
modalContentRef.current && containerResizeObserver?.observe(modalContentRef.current);
}
window.addEventListener('resize', scheduleRefresh);
scheduleRefresh();
return () => {
mutationObserver.disconnect();
window.removeEventListener('resize', scheduleRefresh);
currentTargets.forEach((target) => {
target.removeEventListener('scroll', syncFromTableTarget);
});
if (bodyResizeObserver) {
bodyResizeObserver.disconnect();
}
if (containerResizeObserver) {
containerResizeObserver.disconnect();
}
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
};
}, [open, refreshHorizontalScrollState]);
useEffect(() => {
if (!open) {
return;
@@ -994,198 +821,155 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
}
}, [appendOperationLog, downloadDir, refreshStatus]);
const columns = useMemo(() => {
return [
{
title: '数据源',
dataIndex: 'name',
key: 'name',
width: 150,
},
{
title: '安装包大小',
dataIndex: 'packageSizeText',
key: 'packageSizeText',
width: 120,
render: (_: string | undefined, row: DriverStatusRow) => {
if (row.builtIn) {
return row.packageSizeText || '-';
}
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
const loadingKey = buildVersionSizeLoadingKey(row.type, selectedKey || '');
const selectedOption =
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
options.find((item) => item.recommended) ||
options[0];
const anyKnownSize = options.find((item) => String(item.packageSizeText || '').trim())?.packageSizeText;
if (selectedKey && versionSizeLoadingMap[loadingKey]) {
return '计算中...';
}
return selectedOption?.packageSizeText || anyKnownSize || row.packageSizeText || '-';
},
},
{
title: '状态',
key: 'status',
width: 140,
render: (_: string, row: DriverStatusRow) => {
if (row.builtIn) {
return <Tag color="success"></Tag>;
}
const progress = progressMap[row.type];
if (progress && (progress.status === 'start' || progress.status === 'downloading')) {
return <Tag color="processing"> {Math.round(progress.percent)}%</Tag>;
}
if (row.connectable) {
return <Tag color="success"></Tag>;
}
if (row.packageInstalled) {
return <Tag color="warning"></Tag>;
}
return <Tag color="default"></Tag>;
},
},
{
title: '安装进度',
key: 'progress',
width: 170,
render: (_: string, row: DriverStatusRow) => {
if (row.builtIn) {
return <Text type="secondary">-</Text>;
}
const resolvePackageSizeText = (row: DriverStatusRow): string => {
if (row.builtIn) {
return row.packageSizeText || '-';
}
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
const loadingKey = buildVersionSizeLoadingKey(row.type, selectedKey || '');
const selectedOption =
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
options.find((item) => item.recommended) ||
options[0];
const anyKnownSize = options.find((item) => String(item.packageSizeText || '').trim())?.packageSizeText;
if (selectedKey && versionSizeLoadingMap[loadingKey]) {
return '计算中...';
}
return selectedOption?.packageSizeText || anyKnownSize || row.packageSizeText || '-';
};
const progress = progressMap[row.type];
let percent = 0;
let status: 'normal' | 'exception' | 'active' | 'success' = 'normal';
const resolveDriverStatusTag = (row: DriverStatusRow) => {
if (row.builtIn) {
return <Tag color="success"></Tag>;
}
const progress = progressMap[row.type];
if (progress && (progress.status === 'start' || progress.status === 'downloading')) {
return <Tag color="processing"> {Math.round(progress.percent)}%</Tag>;
}
if (row.needsUpdate) {
return <Tag color="warning"></Tag>;
}
if (row.connectable) {
return <Tag color="success"></Tag>;
}
if (row.packageInstalled) {
return <Tag color="warning"></Tag>;
}
return <Tag></Tag>;
};
if (progress?.status === 'error') {
percent = Math.max(0, Math.min(100, Math.round(progress.percent || 0)));
status = 'exception';
} else if (progress && (progress.status === 'start' || progress.status === 'downloading')) {
percent = Math.max(1, Math.min(99, Math.round(progress.percent || 0)));
status = 'active';
} else if (row.connectable || row.packageInstalled) {
percent = 100;
status = 'success';
}
const resolveDriverProgress = (row: DriverStatusRow) => {
const progress = progressMap[row.type];
let percent = 0;
let status: 'normal' | 'exception' | 'active' | 'success' = 'normal';
return <Progress percent={percent} status={status} size="small" />;
},
},
{
title: '驱动版本',
key: 'driverVersion',
width: 230,
render: (_: string, row: DriverStatusRow) => {
if (row.builtIn) {
return <Text type="secondary">-</Text>;
}
const versionLocked = row.packageInstalled || row.connectable;
if (versionLocked) {
const installedVersion = String(row.installedVersion || '').trim();
if (installedVersion) {
return <Text type="secondary">{installedVersion}</Text>;
if (progress?.status === 'error') {
percent = Math.max(0, Math.min(100, Math.round(progress.percent || 0)));
status = 'exception';
} else if (progress && (progress.status === 'start' || progress.status === 'downloading')) {
percent = Math.max(1, Math.min(99, Math.round(progress.percent || 0)));
status = 'active';
} else if (row.connectable || row.packageInstalled) {
percent = 100;
status = 'success';
}
return { percent, status };
};
const renderVersionControl = (row: DriverStatusRow) => {
if (row.builtIn) {
return <Text type="secondary"></Text>;
}
const versionLocked = row.packageInstalled || row.connectable;
if (versionLocked) {
const installedVersion = String(row.installedVersion || '').trim();
const revisionHint = row.needsUpdate ? ',需重装' : '';
return (
<Text type="secondary" className="driver-manager-version-lock">
{installedVersion ? `${installedVersion}(已安装${revisionHint}` : `已安装${row.needsUpdate ? ',需重装' : ''}`}
</Text>
);
}
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
const selectOptions = buildVersionSelectOptions(options);
const mongoHint = row.type === 'mongodb'
? '当前仅支持 MongoDB 1.17.x 和 2.x更老 1.x 暂不提供安装。'
: '';
return (
<div className="driver-manager-version-control">
<Select
size="small"
style={{ width: '100%' }}
loading={!!versionLoadingMap[row.type]}
disabled={actionState.driverType === row.type}
placeholder={options.length > 0 ? '选择驱动版本' : '点击加载版本'}
value={selectedKey}
options={selectOptions as any}
onOpenChange={(open) => {
if (open && options.length === 0 && !versionLoadingMap[row.type]) {
void loadVersionOptions(row, true);
return;
}
return <Text type="secondary"></Text>;
}
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
const selectOptions = buildVersionSelectOptions(options);
const mongoHint = row.type === 'mongodb'
? '当前仅支持 MongoDB 1.17.x 和 2.x更老 1.x 暂不提供安装。'
: '';
return (
<div style={{ display: 'grid', gap: 4 }}>
<Select
size="small"
style={{ width: '100%' }}
loading={!!versionLoadingMap[row.type]}
disabled={actionState.driverType === row.type}
placeholder={options.length > 0 ? '选择驱动版本' : '点击展开加载版本'}
value={selectedKey}
options={selectOptions as any}
onOpenChange={(open) => {
if (open && options.length === 0 && !versionLoadingMap[row.type]) {
void loadVersionOptions(row, true);
return;
}
if (open && selectedKey) {
void loadVersionPackageSize(row, selectedKey);
}
}}
onChange={(value) => {
setSelectedVersionMap((prev) => ({ ...prev, [row.type]: value }));
void loadVersionPackageSize(row, value);
}}
/>
{mongoHint ? <Text type="secondary" style={{ fontSize: 12 }}>{mongoHint}</Text> : null}
</div>
);
},
},
{
title: '操作',
key: 'actions',
width: 320,
render: (_: string, row: DriverStatusRow) => {
if (row.builtIn) {
return <Text type="secondary">-</Text>;
}
const isSlimBuildUnavailable = (row.message || '').includes('精简构建');
const loadingInstallOrRemove =
actionState.driverType === row.type && (actionState.kind === 'install' || actionState.kind === 'remove');
const loadingLocal = actionState.driverType === row.type && actionState.kind === 'local';
if (isSlimBuildUnavailable && !row.packageInstalled) {
return <Text type="secondary"> Full </Text>;
}
if (open && selectedKey) {
void loadVersionPackageSize(row, selectedKey);
}
}}
onChange={(value) => {
setSelectedVersionMap((prev) => ({ ...prev, [row.type]: value }));
void loadVersionPackageSize(row, value);
}}
/>
{mongoHint ? <Text type="secondary" className="driver-manager-small-text">{mongoHint}</Text> : null}
</div>
);
};
const logs = operationLogMap[row.type] || [];
const hasLogs = logs.length > 0;
const renderDriverActions = (row: DriverStatusRow) => {
if (row.builtIn) {
return null;
}
const isSlimBuildUnavailable = (row.message || '').includes('精简构建');
const loadingInstallOrRemove =
actionState.driverType === row.type && (actionState.kind === 'install' || actionState.kind === 'remove');
const loadingLocal = actionState.driverType === row.type && actionState.kind === 'local';
const logs = operationLogMap[row.type] || [];
const hasLogs = logs.length > 0;
const mainAction = row.connectable ? (
<Button
danger
icon={<DeleteOutlined />}
loading={loadingInstallOrRemove}
onClick={() => removeDriver(row)}
>
</Button>
) : (
<Button
type="primary"
icon={<DownloadOutlined />}
loading={loadingInstallOrRemove}
onClick={() => installDriver(row)}
>
</Button>
);
if (isSlimBuildUnavailable && !row.packageInstalled) {
return <Text type="secondary">使 Full </Text>;
}
return (
<Space size={8} wrap>
{mainAction}
<Button
icon={<FileSearchOutlined />}
loading={loadingLocal}
onClick={() => installDriverFromLocalFile(row)}
>
</Button>
<Button
type={hasLogs ? 'default' : 'text'}
disabled={!hasLogs}
onClick={() => openDriverLog(row.type)}
>
</Button>
</Space>
);
},
},
];
}, [actionState, installDriver, installDriverFromLocalFile, loadVersionOptions, loadVersionPackageSize, openDriverLog, operationLogMap, progressMap, removeDriver, selectedVersionMap, versionLoadingMap, versionMap, versionSizeLoadingMap]);
const mainAction = row.needsUpdate ? (
<Button type="primary" icon={<DownloadOutlined />} loading={loadingInstallOrRemove} onClick={() => installDriver(row)}>
</Button>
) : row.connectable ? (
<Button danger icon={<DeleteOutlined />} loading={loadingInstallOrRemove} onClick={() => removeDriver(row)}>
</Button>
) : (
<Button type="primary" icon={<DownloadOutlined />} loading={loadingInstallOrRemove} onClick={() => installDriver(row)}>
</Button>
);
return (
<Space size={8} wrap className="driver-manager-card-actions">
{mainAction}
<Button icon={<FileSearchOutlined />} loading={loadingLocal} onClick={() => installDriverFromLocalFile(row)}>
{DRIVER_LOCAL_IMPORT_BUTTON_LABEL}
</Button>
<Button type={hasLogs ? 'default' : 'text'} disabled={!hasLogs} onClick={() => openDriverLog(row.type)}>
</Button>
</Space>
);
};
const activeLogRow = useMemo(() => {
if (!logDriverType) {
@@ -1204,9 +988,10 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
row.type,
row.pinnedVersion,
row.installedVersion,
row.updateReason,
row.message,
row.builtIn ? '内置' : '外置',
row.connectable ? '已启用' : row.packageInstalled ? '已安装' : '未启用',
row.needsUpdate ? '强烈建议重装' : row.connectable ? '已启用' : row.packageInstalled ? '已安装' : '未启用',
];
const searchableText = normalizeDriverSearchText(searchableParts.filter(Boolean).join(' '));
return searchableText.includes(normalizedSearchKeyword);
@@ -1218,6 +1003,87 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
}
return `${rows.length} 个驱动`;
}, [filteredRows.length, normalizedSearchKeyword, rows.length]);
const statusSummary = useMemo(() => {
const optionalRows = rows.filter((row) => !row.builtIn);
return {
total: rows.length,
enabled: optionalRows.filter((row) => row.connectable).length,
needsUpdate: optionalRows.filter((row) => row.needsUpdate).length,
notEnabled: optionalRows.filter((row) => !row.connectable && !row.packageInstalled).length,
};
}, [rows]);
const renderDriverCard = (row: DriverStatusRow) => {
const progress = resolveDriverProgress(row);
const hasActiveProgress = !!progressMap[row.type] || row.connectable || row.packageInstalled;
const issueText = String(row.updateReason || row.message || '').trim();
const affectedText = row.affectedConnections && row.affectedConnections > 0
? `影响 ${row.affectedConnections} 个已保存连接`
: '';
return (
<div
key={row.type}
className={[
'driver-manager-card',
row.needsUpdate ? 'driver-manager-card-warning' : '',
row.connectable ? 'driver-manager-card-ready' : '',
].filter(Boolean).join(' ')}
>
<div className="driver-manager-card-main">
<div className="driver-manager-card-info">
<div className="driver-manager-title-row">
<Text strong className="driver-manager-driver-name">{row.name}</Text>
<Tag>{row.type}</Tag>
{resolveDriverStatusTag(row)}
</div>
<div className="driver-manager-meta-row">
<Text type="secondary">{resolvePackageSizeText(row)}</Text>
<Text type="secondary">{row.installedVersion || row.pinnedVersion || '-'}</Text>
{affectedText ? <Text type="secondary">{affectedText}</Text> : null}
</div>
{row.needsUpdate && issueText ? (
<div className="driver-manager-update-note">
<Text strong type="warning"></Text>
<Paragraph
className="driver-manager-note-text"
ellipsis={{ rows: 2, expandable: true, symbol: '展开原因' }}
>
{issueText}
</Paragraph>
</div>
) : issueText ? (
<Paragraph
className="driver-manager-muted-message"
type="secondary"
ellipsis={{ rows: 2, expandable: true, symbol: '展开' }}
>
{issueText}
</Paragraph>
) : null}
</div>
<div className="driver-manager-card-controls">
<div className="driver-manager-control-block">
<Text type="secondary" className="driver-manager-control-label"></Text>
{renderVersionControl(row)}
</div>
<div className="driver-manager-control-block">
<Text type="secondary" className="driver-manager-control-label"></Text>
{row.builtIn ? (
<Text type="secondary"></Text>
) : hasActiveProgress ? (
<Progress percent={progress.percent} status={progress.status} size="small" />
) : (
<Progress percent={0} size="small" />
)}
</div>
{renderDriverActions(row)}
</div>
</div>
</div>
);
};
const activeDriverLogs = operationLogMap[logDriverType] || [];
const activeDriverLogLines = activeDriverLogs.map((item) => `[${item.time}] ${item.text}`);
@@ -1245,8 +1111,9 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
title="驱动管理"
open={open}
onCancel={onClose}
width={980}
width={1120}
style={{ top: 24 }}
className="driver-manager-modal"
styles={{
body: {
maxHeight: 'calc(100vh - 220px)',
@@ -1257,32 +1124,46 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
}}
destroyOnHidden
footer={(
<div className="driver-manager-footer">
<div
ref={externalHScrollRef}
className="driver-manager-hscroll"
aria-hidden={false}
onScroll={applyExternalScrollToTableTargets}
>
<div className="driver-manager-hscroll-inner" style={{ width: `${Math.max(horizontalScrollWidth, 1)}px` }} />
</div>
<Space className="driver-manager-footer-actions" size={8}>
<Button key="refresh" icon={<ReloadOutlined />} onClick={() => refreshStatus(true)} loading={loading}>
</Button>
<Button key="network" onClick={() => checkNetworkStatus(true)} loading={networkChecking}>
</Button>
<Button key="close" type="primary" onClick={onClose}>
</Button>
</Space>
</div>
<Space className="driver-manager-footer-actions" size={8}>
<Button key="refresh" icon={<ReloadOutlined />} onClick={() => refreshStatus(true)} loading={loading}>
</Button>
<Button key="network" onClick={() => checkNetworkStatus(true)} loading={networkChecking}>
</Button>
<Button key="close" type="primary" onClick={onClose}>
</Button>
</Space>
)}
>
<div ref={modalContentRef}>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Text type="secondary"> MySQL / Redis / Oracle / PostgreSQL </Text>
<div className="driver-manager-shell">
<div className="driver-manager-header">
<div className="driver-manager-heading">
<Text type="secondary"> MySQL / Redis / Oracle / PostgreSQL </Text>
<Text type="secondary">GoNavi agent </Text>
</div>
<div className="driver-manager-stats">
<div className="driver-manager-stat">
<span>{statusSummary.total}</span>
<Text type="secondary"></Text>
</div>
<div className="driver-manager-stat">
<span>{statusSummary.enabled}</span>
<Text type="secondary"></Text>
</div>
<div className="driver-manager-stat driver-manager-stat-warning">
<span>{statusSummary.needsUpdate}</span>
<Text type="secondary"></Text>
</div>
<div className="driver-manager-stat">
<span>{statusSummary.notEnabled}</span>
<Text type="secondary"></Text>
</div>
</div>
</div>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{networkStatus ? (
networkUnreachable ? (
<Alert
@@ -1358,51 +1239,43 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
/>
)}
<Alert
type="info"
showIcon
icon={sharedInfoAlertIcon}
message="驱动目录与复用说明"
description={(
<Collapse
size="small"
items={[
{
key: 'driver-directory',
label: '查看驱动目录与复用说明',
children: (
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text type="secondary"></Text>
<Text type="secondary">使</Text>
<Text type="secondary">/ `mariadb-driver-agent``mariadb-driver-agent.exe``GoNavi-DriverAgents.zip`使</Text>
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
{downloadDir || '-'}
<div className="driver-manager-directory-panel">
<Collapse
size="small"
ghost
items={[
{
key: 'driver-directory',
label: '驱动目录与手动导入说明',
children: (
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text type="secondary"></Text>
<Text type="secondary">{DRIVER_LOCAL_IMPORT_DIRECTORY_HELP}</Text>
<Text type="secondary">{DRIVER_LOCAL_IMPORT_SINGLE_FILE_HELP}</Text>
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
{downloadDir || '-'}
</Paragraph>
{networkStatus?.logPath ? (
<Paragraph copyable={{ text: networkStatus.logPath }} style={{ marginBottom: 0 }}>
{networkStatus.logPath}
</Paragraph>
<Button icon={<FolderOpenOutlined />} onClick={() => void openDriverDirectory()}>
</Button>
{networkStatus?.logPath ? (
<Paragraph copyable={{ text: networkStatus.logPath }} style={{ marginBottom: 0 }}>
{networkStatus.logPath}
</Paragraph>
) : null}
</Space>
),
},
]}
/>
)}
/>
) : null}
</Space>
),
},
]}
/>
</div>
<div style={{ width: '100%', display: 'flex', gap: 12, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<div className="driver-manager-toolbar">
<Input.Search
allowClear
placeholder="搜索驱动名称/类型(如 DuckDB、clickhouse"
value={searchKeyword}
onChange={(event) => setSearchKeyword(event.target.value)}
style={{ minWidth: 300, flex: '1 1 360px' }}
className="driver-manager-search"
/>
<Space size={8}>
<Space size={8} wrap className="driver-manager-toolbar-actions">
<Text type="secondary"></Text>
<Switch
checked={forceOverwriteInstalled}
@@ -1424,30 +1297,22 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
</Button>
</Space>
</div>
<Text type="secondary">{filterSummaryText}</Text>
<div
ref={tableContainerRef}
className="driver-manager-table-wrap driver-manager-table-wrap-external-active"
>
<Table
className="driver-manager-table"
rowKey="type"
loading={loading}
columns={columns as any}
dataSource={filteredRows}
pagination={false}
size="middle"
sticky={false}
scroll={{ x: DRIVER_TABLE_SCROLL_X }}
locale={{
emptyText: normalizedSearchKeyword
? `未找到匹配“${String(searchKeyword || '').trim()}”的驱动`
: '暂无驱动数据',
}}
/>
<div className="driver-manager-list-head">
<Text type="secondary">{filterSummaryText}</Text>
{loading ? <Text type="secondary">...</Text> : null}
</div>
</Space>
<div className="driver-manager-list">
{filteredRows.length > 0 ? (
filteredRows.map(renderDriverCard)
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={normalizedSearchKeyword ? `未找到匹配“${String(searchKeyword || '').trim()}”的驱动` : '暂无驱动数据'}
/>
)}
</div>
</Space>
</div>
<Modal
title={`驱动日志 - ${activeLogRow?.name || logDriverType}`}

View File

@@ -6,6 +6,7 @@ import { quoteIdentPart, escapeLiteral } from '../utils/sql';
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { isMacLikePlatform } from '../utils/appearance';
interface FindInDatabaseModalProps {
open: boolean;
@@ -67,14 +68,15 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const disableLocalBackdropFilter = isMacLikePlatform();
const conn = useMemo(() => connections.find(c => c.id === connectionId), [connections, connectionId]);
const dbType = useMemo(() => (conn?.config?.type || 'mysql').toLowerCase(), [conn]);
const wt = useMemo(() => {
const isDark = theme === 'dark';
return buildOverlayWorkbenchTheme(isDark);
}, [theme]);
return buildOverlayWorkbenchTheme(isDark, { disableBackdropFilter: disableLocalBackdropFilter });
}, [disableLocalBackdropFilter, theme]);
const buildConfig = useCallback(() => {
if (!conn) return null;

View File

@@ -0,0 +1,48 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import JVMAuditViewer from "./JVMAuditViewer";
vi.mock("../store", () => ({
useStore: (selector: (state: any) => any) =>
selector({
connections: [
{
id: "conn-jvm-1",
name: "orders-jvm",
config: {
host: "localhost",
port: 10990,
jvm: {
preferredMode: "endpoint",
readOnly: false,
},
},
},
],
theme: "light",
}),
}));
describe("JVMAuditViewer", () => {
it("renders a unified JVM workspace audit shell", () => {
const markup = renderToStaticMarkup(
<JVMAuditViewer
tab={{
id: "tab-jvm-audit",
type: "jvm-audit",
title: "[orders-jvm] JVM 审计",
connectionId: "conn-jvm-1",
providerMode: "endpoint",
} as any}
/>,
);
expect(markup).toContain('data-jvm-workspace-shell="true"');
expect(markup).toContain('data-jvm-workspace-hero="true"');
expect(markup).toContain("JVM 变更审计");
expect(markup).toContain("审计记录");
expect(markup).toContain("最近 50 条");
});
});

View File

@@ -0,0 +1,271 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Alert,
Button,
Card,
Empty,
Select,
Space,
Table,
Tag,
Typography,
} from "antd";
import type { ColumnsType } from "antd/es/table";
import { ReloadOutlined } from "@ant-design/icons";
import { useStore } from "../store";
import type { JVMAuditRecord, TabData } from "../types";
import {
formatJVMAuditResultLabel,
formatJVMActionDisplayText,
resolveJVMAuditResultColor,
} from "../utils/jvmResourcePresentation";
import JVMModeBadge from "./jvm/JVMModeBadge";
import {
getJVMWorkspaceCardStyle,
JVMWorkspaceHero,
JVMWorkspaceShell,
} from "./jvm/JVMWorkspaceLayout";
const { Text } = Typography;
type JVMAuditViewerProps = {
tab: TabData;
};
const LIMIT_OPTIONS = [20, 50, 100, 200];
const normalizeAuditRecords = (value: any): JVMAuditRecord[] => {
if (Array.isArray(value)) {
return value as JVMAuditRecord[];
}
if (Array.isArray(value?.data)) {
return value.data as JVMAuditRecord[];
}
return [];
};
const filterAuditRecordsByMode = (
records: JVMAuditRecord[],
providerMode?: string,
): JVMAuditRecord[] => {
const normalizedMode = String(providerMode || "")
.trim()
.toLowerCase();
if (!normalizedMode) {
return records;
}
return records.filter(
(record) =>
String(record.providerMode || "")
.trim()
.toLowerCase() === normalizedMode,
);
};
const formatTimestamp = (timestamp: number): string => {
if (!timestamp) {
return "-";
}
const normalized = timestamp > 1e12 ? timestamp : timestamp * 1000;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) {
return String(timestamp);
}
return date.toLocaleString("zh-CN", { hour12: false });
};
const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
const connection = useStore((state) =>
state.connections.find((item) => item.id === tab.connectionId),
);
const theme = useStore((state) => state.theme);
const darkMode = theme === "dark";
const [limit, setLimit] = useState(50);
const [loading, setLoading] = useState(true);
const [records, setRecords] = useState<JVMAuditRecord[]>([]);
const [error, setError] = useState("");
const columns = useMemo<ColumnsType<JVMAuditRecord>>(
() => [
{
title: "时间",
dataIndex: "timestamp",
key: "timestamp",
width: 180,
render: (value: number) => formatTimestamp(value),
},
{
title: "模式",
dataIndex: "providerMode",
key: "providerMode",
width: 120,
render: (value: string) => (
<JVMModeBadge mode={value || tab.providerMode || "jmx"} />
),
},
{
title: "动作",
dataIndex: "action",
key: "action",
width: 160,
render: (value: string) => formatJVMActionDisplayText(value) || "-",
},
{
title: "资源",
dataIndex: "resourceId",
key: "resourceId",
ellipsis: true,
render: (value: string) => value || "-",
},
{
title: "原因",
dataIndex: "reason",
key: "reason",
ellipsis: true,
render: (value: string) => value || "-",
},
{
title: "来源",
dataIndex: "source",
key: "source",
width: 120,
render: (value?: string) => {
const normalized = String(value || "")
.trim()
.toLowerCase();
if (normalized === "ai-plan") {
return <Tag color="purple">AI </Tag>;
}
return <Tag></Tag>;
},
},
{
title: "结果",
dataIndex: "result",
key: "result",
width: 140,
render: (value: string) => (
<Tag color={resolveJVMAuditResultColor(value)}>
{formatJVMAuditResultLabel(value)}
</Tag>
),
},
],
[tab.providerMode],
);
const loadRecords = async () => {
if (!connection) {
setLoading(false);
setRecords([]);
setError("连接不存在或已被删除");
return;
}
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.JVMListAuditRecords !== "function") {
setLoading(false);
setRecords([]);
setError("JVMListAuditRecords 后端方法不可用");
return;
}
setLoading(true);
setError("");
try {
const result = await backendApp.JVMListAuditRecords(connection.id, limit);
if (result?.success === false) {
setRecords([]);
setError(String(result?.message || "读取 JVM 审计记录失败"));
return;
}
setRecords(
filterAuditRecordsByMode(
normalizeAuditRecords(result),
tab.providerMode,
),
);
} catch (err: any) {
setRecords([]);
setError(err?.message || "读取 JVM 审计记录失败");
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadRecords();
}, [connection, limit, tab.connectionId]);
if (!connection) {
return (
<Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />
);
}
const activeMode =
tab.providerMode || connection.config.jvm?.preferredMode || "jmx";
const cardStyle = getJVMWorkspaceCardStyle(darkMode);
return (
<JVMWorkspaceShell darkMode={darkMode}>
<JVMWorkspaceHero
darkMode={darkMode}
eyebrow="JVM Audit"
title="JVM 变更审计"
description={
<>
<Text strong>{connection.name}</Text>
<Text type="secondary"> · {connection.id}</Text>
<Text type="secondary"> · {limit} </Text>
</>
}
badges={<JVMModeBadge mode={activeMode} />}
actions={
<>
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => void loadRecords()}
>
</Button>
<Select
size="small"
value={limit}
onChange={setLimit}
options={LIMIT_OPTIONS.map((item) => ({
value: item,
label: `最近 ${item}`,
}))}
style={{ width: 132 }}
/>
</>
}
/>
<Card title="审计记录" variant="borderless" style={cardStyle}>
<Space direction="vertical" size={16} style={{ width: "100%" }}>
{error ? <Alert type="error" showIcon message={error} /> : null}
<Table<JVMAuditRecord>
rowKey={(record) =>
`${record.timestamp}-${record.resourceId}-${record.action}`
}
loading={loading}
columns={columns}
dataSource={records}
pagination={false}
locale={{
emptyText: error ? "当前无法加载审计记录" : "暂无审计记录",
}}
scroll={{ x: 960 }}
size="small"
/>
</Space>
</Card>
</JVMWorkspaceShell>
);
};
export default JVMAuditViewer;

View File

@@ -0,0 +1,946 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { act, create } from "react-test-renderer";
import { message } from "antd";
import { beforeEach, describe, expect, it, vi } from "vitest";
import JVMDiagnosticConsole, {
createJVMDiagnosticLocalPendingChunk,
createJVMDiagnosticRunningRecord,
isJVMDiagnosticTerminalPhase,
} from "./JVMDiagnosticConsole";
const baseState = {
connections: [
{
id: "conn-1",
name: "orders-jvm",
config: {
host: "orders.internal",
jvm: {
diagnostic: {
enabled: true,
transport: "agent-bridge",
},
},
},
},
],
jvmDiagnosticDrafts: {},
jvmDiagnosticOutputs: {},
setJVMDiagnosticDraft: vi.fn(),
appendJVMDiagnosticOutput: vi.fn(),
clearJVMDiagnosticOutput: vi.fn(),
};
let mockState: any = baseState;
let registeredCompletionProvider: any = null;
let registeredDiagnosticChunkHandler: any = null;
const mockBackendApp = {
JVMListDiagnosticAuditRecords: vi.fn(),
JVMExecuteDiagnosticCommand: vi.fn(),
};
const mockMonaco = {
Range: class {
startLineNumber: number;
startColumn: number;
endLineNumber: number;
endColumn: number;
constructor(
startLineNumber: number,
startColumn: number,
endLineNumber: number,
endColumn: number,
) {
this.startLineNumber = startLineNumber;
this.startColumn = startColumn;
this.endLineNumber = endLineNumber;
this.endColumn = endColumn;
}
},
KeyMod: { CtrlCmd: 2048 },
KeyCode: { Enter: 3 },
editor: {
setTheme: vi.fn(),
},
languages: {
CompletionItemKind: {
Keyword: 1,
Snippet: 2,
Value: 3,
},
CompletionItemInsertTextRule: {
InsertAsSnippet: 4,
},
register: vi.fn(),
registerCompletionItemProvider: vi.fn((language: string, provider: any) => {
if (language === "jvm-diagnostic") {
registeredCompletionProvider = provider;
}
return { dispose: vi.fn() };
}),
},
};
const mockEditor = {
addCommand: vi.fn(),
};
vi.mock("@monaco-editor/react", () => ({
default: ({
beforeMount,
language,
onMount,
value,
}: {
beforeMount?: (monaco: any) => void;
language?: string;
onMount?: (editor: any, monaco: any) => void;
value?: string;
}) => {
beforeMount?.(mockMonaco);
onMount?.(mockEditor, mockMonaco);
return (
<div
data-before-mount={beforeMount ? "true" : "false"}
data-monaco-editor-mock="true"
data-language={language}
>
{value}
</div>
);
},
}));
vi.mock("../../wailsjs/runtime", () => ({
EventsOn: vi.fn((_eventName: string, handler: any) => {
registeredDiagnosticChunkHandler = handler;
return vi.fn();
}),
}));
vi.mock("@ant-design/icons", () => {
const Icon = () => <span />;
return {
ClearOutlined: Icon,
HistoryOutlined: Icon,
PauseCircleOutlined: Icon,
PlayCircleOutlined: Icon,
ReloadOutlined: Icon,
RocketOutlined: Icon,
ToolOutlined: Icon,
};
});
vi.mock("antd", () => {
const passthrough = ({ children, style }: any) => <div style={style}>{children}</div>;
const Text = ({ children, style }: any) => <span style={style}>{children}</span>;
const Paragraph = ({ children, style }: any) => <p style={style}>{children}</p>;
const Title = ({ children, style }: any) => <h3 style={style}>{children}</h3>;
const Empty = ({ description }: any) => <div>{description}</div>;
Empty.PRESENTED_IMAGE_SIMPLE = "simple";
const List = ({ dataSource = [], renderItem }: any) => (
<div>{dataSource.map((item: any, index: number) => renderItem(item, index))}</div>
);
List.Item = ({ children, style }: any) => <div style={style}>{children}</div>;
const Typography = { Text, Paragraph, Title };
return {
Alert: ({ message: alertMessage, description, style }: any) => (
<div style={style}>{alertMessage}{description}</div>
),
Button: ({ children, onClick, disabled, style }: any) => <button onClick={onClick} disabled={disabled} style={style}>{children}</button>,
Card: ({ children, title, style }: any) => <section style={style}>{title}{children}</section>,
Empty,
Input: ({ value, onChange, placeholder }: any) => <input value={value} onChange={onChange} placeholder={placeholder} />,
List,
Space: passthrough,
Tag: ({ children, style }: any) => <span style={style}>{children}</span>,
Typography,
message: {
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
};
});
vi.mock("../store", () => ({
useStore: (selector: (state: any) => any) => selector(mockState),
}));
describe("JVMDiagnosticConsole", () => {
beforeEach(() => {
registeredCompletionProvider = null;
registeredDiagnosticChunkHandler = null;
mockState = {
...baseState,
setJVMDiagnosticDraft: vi.fn(),
appendJVMDiagnosticOutput: vi.fn(),
clearJVMDiagnosticOutput: vi.fn(),
};
mockBackendApp.JVMListDiagnosticAuditRecords.mockResolvedValue({
success: true,
data: [],
});
mockBackendApp.JVMExecuteDiagnosticCommand.mockReset();
vi.mocked(message.success).mockClear();
vi.mocked(message.warning).mockClear();
vi.mocked(message.info).mockClear();
(globalThis as any).window = {
...(globalThis as any).window,
go: { app: { App: mockBackendApp } },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
mockMonaco.editor.setTheme.mockClear();
mockMonaco.languages.register.mockClear();
mockMonaco.languages.registerCompletionItemProvider.mockClear();
mockEditor.addCommand.mockClear();
});
it("builds local pending output and history while a command is waiting for backend events", () => {
const chunk = createJVMDiagnosticLocalPendingChunk({
sessionId: "session-1",
commandId: "cmd-1",
command: "thread -n 5",
});
const record = createJVMDiagnosticRunningRecord({
connectionId: "conn-1",
sessionId: "session-1",
commandId: "cmd-1",
transport: "arthas-tunnel",
command: "thread -n 5",
source: "manual",
reason: "排查线程",
});
expect(chunk).toMatchObject({
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "running",
});
expect(chunk.content).toContain("thread -n 5");
expect(record).toMatchObject({
connectionId: "conn-1",
sessionId: "session-1",
commandId: "cmd-1",
transport: "arthas-tunnel",
command: "thread -n 5",
status: "running",
reason: "排查线程",
});
expect(isJVMDiagnosticTerminalPhase("completed")).toBe(true);
expect(isJVMDiagnosticTerminalPhase("failed")).toBe(true);
expect(isJVMDiagnosticTerminalPhase("running")).toBe(false);
});
it("keeps a stable workbench shell and hides command inputs before session creation", () => {
mockState = {
...baseState,
jvmDiagnosticDrafts: {},
};
const markup = renderToStaticMarkup(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
expect(markup).toContain("开始一次诊断");
expect(markup).toContain("命令输入将在会话建立后显示");
expect(markup).toContain("先建立会话,再显示命令编辑器和模板");
expect(markup).toContain("会话与能力");
expect(markup).toContain("审计历史");
expect(markup).not.toContain("命令模板");
expect(markup).not.toContain("实时输出");
expect(markup).not.toContain('data-monaco-editor-mock="true"');
});
it("shows command input, reason field, and presets after a session exists", () => {
mockState = {
...baseState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
reason: "排查 CPU 线程",
},
},
};
const markup = renderToStaticMarkup(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
expect(markup).toContain("overflow:auto");
expect(markup).toContain("JVM 诊断工作台");
expect(markup).toContain("会话与能力");
expect(markup).toContain("实时输出");
expect(markup).toContain("审计历史");
expect(markup.indexOf("命令输入")).toBeGreaterThanOrEqual(0);
expect(markup).toContain("诊断命令");
expect(markup).toContain("诊断原因(可选)");
expect(markup).toContain("用于审计记录");
expect(markup.indexOf("命令输入")).toBeLessThan(markup.indexOf("实时输出"));
expect(markup).toContain("观察类命令");
expect(markup).toContain("thread");
expect(markup).toContain("执行命令");
expect(markup).toContain('data-monaco-editor-mock="true"');
expect(markup).toContain('data-language="jvm-diagnostic"');
});
it("redacts sensitive diagnostic output in the rendered console", () => {
mockState = {
...baseState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "watch com.foo.SecretService read '{returnObj}'",
},
},
jvmDiagnosticOutputs: {
"tab-1": [
{
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "running",
content: "password=secret-token\napiKey: api-key-secret",
},
],
},
};
const markup = renderToStaticMarkup(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
expect(markup).toContain("password=********");
expect(markup).toContain("apiKey: ********");
expect(markup).not.toContain("secret-token");
expect(markup).not.toContain("api-key-secret");
});
it("uses the same styled editor shell and registers command completion before mount", () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thr",
reason: "排查 CPU 线程",
},
},
};
const markup = renderToStaticMarkup(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
expect(markup).toContain(
'data-jvm-diagnostic-command-editor-shell="true"',
);
expect(markup).toContain('data-before-mount="true"');
expect(markup).toContain("border-radius:14px");
expect(registeredCompletionProvider).toBeTruthy();
const result = registeredCompletionProvider.provideCompletionItems(
{
getValueInRange: () => "thr",
getWordUntilPosition: () => ({ startColumn: 1, endColumn: 4 }),
},
{ lineNumber: 1, column: 4 },
);
expect(result.suggestions).toEqual(
expect.arrayContaining([
expect.objectContaining({
label: "thread",
insertText: "thread ",
}),
]),
);
});
it("redacts failed diagnostic event content before storing and alerting", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let renderer: any;
await act(async () => {
renderer = create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "failed",
content: "def456\n-----END PRIVATE KEY-----",
},
});
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
expect(JSON.stringify(renderer.toJSON())).not.toContain("def456");
});
it("redacts successful diagnostic warning messages", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
mockBackendApp.JVMExecuteDiagnosticCommand.mockResolvedValue({
success: true,
message: "api_key=query-secret",
});
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
expect(message.warning).toHaveBeenCalledWith("api_key=********");
expect(message.warning).not.toHaveBeenCalledWith(
expect.stringContaining("query-secret"),
);
});
it("redacts successful diagnostic warning messages with the active diagnostic stream state", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let resolveCommand: (value: any) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((resolve) => {
resolveCommand = resolve;
}),
);
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
});
await act(async () => {
resolveCommand({
success: true,
message: "def456\n-----END PRIVATE KEY-----",
});
});
expect(JSON.stringify((message.warning as any).mock.calls)).not.toContain(
"def456",
);
});
it("keeps diagnostic redaction state after clearing visible output", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let renderer: any;
await act(async () => {
renderer = create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIV",
},
});
});
const clearButton = renderer.root
.findAllByType("button")
.find((button: any) => button.children.includes("清空输出"));
await act(async () => {
clearButton.props.onClick();
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "failed",
content: "ATE KEY-----\nabc123\n-----END PRIVATE KEY-----",
},
});
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(mockState.clearJVMDiagnosticOutput).toHaveBeenCalledWith("tab-1");
expect(JSON.stringify(appendedChunks)).not.toContain("ATE KEY");
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
});
it("redacts frontend fallback errors with the active diagnostic stream state", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let rejectCommand: (error: Error) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((_resolve, reject) => {
rejectCommand = reject;
}),
);
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
});
await act(async () => {
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
});
it("keeps diagnostic redaction state after local completion fallback", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let resolveCommand: (value: any) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((resolve) => {
resolveCommand = resolve;
}),
);
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
});
await act(async () => {
resolveCommand({ success: true });
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "completed",
content: "def456\n-----END PRIVATE KEY-----",
},
});
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
});
it("redacts terminal-seen execute errors with the active diagnostic stream state", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let rejectCommand: (error: Error) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((_resolve, reject) => {
rejectCommand = reject;
}),
);
let renderer: any;
await act(async () => {
renderer = create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "completed",
content: "still waiting for execute call",
},
});
});
await act(async () => {
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
});
expect(JSON.stringify(renderer.toJSON())).not.toContain("def456");
});
it("redacts execute errors after a real failed terminal event closes the active PEM stream", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let rejectCommand: (error: Error) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((_resolve, reject) => {
rejectCommand = reject;
}),
);
let renderer: any;
await act(async () => {
renderer = create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "failed",
content: "def456\n-----END PRIVATE KEY-----",
},
});
});
await act(async () => {
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
});
expect(JSON.stringify(renderer.toJSON())).not.toContain("def456");
});
it("redacts delayed failed terminal events after frontend fallback closes the active PEM stream", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let rejectCommand: (error: Error) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((_resolve, reject) => {
rejectCommand = reject;
}),
);
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
});
await act(async () => {
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "failed",
content: "def456\n-----END PRIVATE KEY-----",
},
});
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import JVMMonitoringDashboard from "./JVMMonitoringDashboard";
vi.mock("../store", () => ({
useStore: (selector: (state: any) => any) =>
selector({
theme: "light",
connections: [
{
id: "conn-1",
name: "orders-jvm",
config: {
host: "orders.internal",
port: 9010,
jvm: {
preferredMode: "jmx",
allowedModes: ["jmx"],
},
},
},
],
}),
}));
describe("JVMMonitoringDashboard", () => {
it("shows start action and empty-state guidance before monitoring starts", () => {
const markup = renderToStaticMarkup(
<JVMMonitoringDashboard
tab={{
id: "tab-monitor-1",
title: "持续监控",
type: "jvm-monitoring",
connectionId: "conn-1",
providerMode: "jmx",
}}
/>,
);
expect(markup).toContain("开始监控");
expect(markup).toContain("当前尚未开始持续监控");
expect(markup).toContain("堆内存");
expect(markup).toContain("暂无堆内存采样数据");
expect(markup).not.toContain("暂无 Heap 采样数据");
expect(markup).not.toContain("当前 provider 未提供 Heap 指标");
});
it("renders a dedicated vertical scroll shell for tall monitoring content", () => {
const markup = renderToStaticMarkup(
<JVMMonitoringDashboard
tab={{
id: "tab-monitor-scroll",
title: "持续监控",
type: "jvm-monitoring",
connectionId: "conn-1",
providerMode: "jmx",
}}
/>,
);
expect(markup).toContain('data-jvm-monitoring-dashboard-scroll-shell="true"');
expect(markup).toContain("height:100%");
expect(markup).toContain("overflow-y:auto");
});
it("stacks monitoring charts before detail panels so charts keep full content width", () => {
const markup = renderToStaticMarkup(
<JVMMonitoringDashboard
tab={{
id: "tab-monitor-layout",
title: "持续监控",
type: "jvm-monitoring",
connectionId: "conn-1",
providerMode: "jmx",
}}
/>,
);
expect(markup).toContain('data-jvm-monitoring-content-stack="true"');
expect(markup).toContain("gap:24px");
expect(markup).not.toContain("minmax(min(100%, 320px), 1fr)");
});
});

View File

@@ -0,0 +1,392 @@
import React, { useEffect, useMemo, useState } from "react";
import { Alert, Button, Card, Empty, Space, Spin, Tag, Typography } from "antd";
import { DashboardOutlined, PauseCircleOutlined, PlayCircleOutlined, ReloadOutlined } from "@ant-design/icons";
import { useStore } from "../store";
import type { JVMMonitoringSessionState, TabData } from "../types";
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
import {
buildMonitoringAvailabilityText,
normalizeMonitoringProviderMode,
type JVMMonitoringProviderMode,
} from "../utils/jvmMonitoringPresentation";
import { resolveJVMModeMeta } from "../utils/jvmRuntimePresentation";
import JVMMonitoringCharts from "./jvm/JVMMonitoringCharts";
import JVMMonitoringDetailPanel from "./jvm/JVMMonitoringDetailPanel";
import JVMMonitoringStatusCards from "./jvm/JVMMonitoringStatusCards";
const { Paragraph, Text, Title } = Typography;
const POLL_INTERVAL_MS = 2000;
type JVMMonitoringDashboardProps = {
tab: TabData;
};
const isMonitoringSessionMissing = (message: string): boolean =>
/monitoring session not found/i.test(String(message || ""));
const createEmptySession = (
connectionId: string,
providerMode: JVMMonitoringProviderMode,
): JVMMonitoringSessionState => ({
connectionId,
providerMode,
running: false,
points: [],
recentGcEvents: [],
availableMetrics: [],
missingMetrics: [],
providerWarnings: [],
});
const normalizeMonitoringSession = (
payload: any,
connectionId: string,
providerMode: JVMMonitoringProviderMode,
): JVMMonitoringSessionState => ({
connectionId: String(payload?.connectionId || connectionId),
providerMode: normalizeMonitoringProviderMode(payload?.providerMode, providerMode),
running: payload?.running === true,
points: Array.isArray(payload?.points) ? payload.points : [],
recentGcEvents: Array.isArray(payload?.recentGcEvents) ? payload.recentGcEvents : [],
availableMetrics: Array.isArray(payload?.availableMetrics)
? payload.availableMetrics
: [],
missingMetrics: Array.isArray(payload?.missingMetrics) ? payload.missingMetrics : [],
providerWarnings: Array.isArray(payload?.providerWarnings)
? payload.providerWarnings
: [],
});
const resolveBackendApp = () =>
typeof window === "undefined" ? undefined : (window as any).go?.app?.App;
const JVMMonitoringDashboard: React.FC<JVMMonitoringDashboardProps> = ({ tab }) => {
const theme = useStore((state) => state.theme);
const connection = useStore((state) =>
state.connections.find((item) => item.id === tab.connectionId),
);
const darkMode = theme === "dark";
const providerMode = normalizeMonitoringProviderMode(
tab.providerMode,
normalizeMonitoringProviderMode(connection?.config.jvm?.preferredMode, "jmx"),
);
const [session, setSession] = useState<JVMMonitoringSessionState>(() =>
createEmptySession(tab.connectionId, providerMode),
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [actionLoading, setActionLoading] = useState(false);
const [pollSeed, setPollSeed] = useState(0);
const rpcConnectionConfig = useMemo(() => {
if (!connection) {
return null;
}
return buildRpcConnectionConfig(connection.config, {
database: "",
jvm: {
...(connection.config.jvm || {}),
preferredMode: providerMode,
allowedModes: [providerMode],
},
});
}, [connection, providerMode]);
const latestPoint = useMemo(() => {
const points = session.points || [];
return points.length > 0 ? points[points.length - 1] : undefined;
}, [session.points]);
useEffect(() => {
setSession(createEmptySession(tab.connectionId, providerMode));
}, [tab.connectionId, providerMode]);
useEffect(() => {
if (!connection || !rpcConnectionConfig) {
setLoading(false);
return;
}
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const backendApp = resolveBackendApp();
const poll = async () => {
if (cancelled) {
return;
}
setLoading(true);
if (typeof backendApp?.JVMGetMonitoringHistory !== "function") {
setError("JVMGetMonitoringHistory 后端方法不可用");
setLoading(false);
return;
}
try {
const result = await backendApp.JVMGetMonitoringHistory(
rpcConnectionConfig,
providerMode,
);
if (cancelled) {
return;
}
if (result?.success === false) {
const message = String(result?.message || "读取监控历史失败");
if (isMonitoringSessionMissing(message)) {
setSession(createEmptySession(tab.connectionId, providerMode));
setError("");
setLoading(false);
return;
}
throw new Error(message);
}
const nextSession = normalizeMonitoringSession(
result?.data,
tab.connectionId,
providerMode,
);
setSession(nextSession);
setError("");
setLoading(false);
if (nextSession.running) {
timer = setTimeout(poll, POLL_INTERVAL_MS);
}
} catch (fetchError: any) {
if (!cancelled) {
setError(fetchError?.message || "读取监控历史失败");
setLoading(false);
}
}
};
void poll();
return () => {
cancelled = true;
if (timer) {
clearTimeout(timer);
}
};
}, [connection, providerMode, rpcConnectionConfig, tab.connectionId, pollSeed]);
if (!connection) {
return <Empty description="连接不存在或已被删除" style={{ marginTop: 80 }} />;
}
const backendApp = resolveBackendApp();
const availabilityText = buildMonitoringAvailabilityText(session);
const modeMeta = resolveJVMModeMeta(providerMode);
const emptyState = !session.running && (session.points || []).length === 0;
const handleStart = async () => {
if (!rpcConnectionConfig || typeof backendApp?.JVMStartMonitoring !== "function") {
setError("JVMStartMonitoring 后端方法不可用");
return;
}
setActionLoading(true);
setError("");
try {
const result = await backendApp.JVMStartMonitoring(rpcConnectionConfig);
if (result?.success === false) {
throw new Error(String(result?.message || "开始监控失败"));
}
setSession(
normalizeMonitoringSession(result?.data, tab.connectionId, providerMode),
);
setPollSeed((current) => current + 1);
} catch (startError: any) {
setError(startError?.message || "开始监控失败");
} finally {
setActionLoading(false);
}
};
const handleStop = async () => {
if (!rpcConnectionConfig || typeof backendApp?.JVMStopMonitoring !== "function") {
setError("JVMStopMonitoring 后端方法不可用");
return;
}
setActionLoading(true);
setError("");
try {
const result = await backendApp.JVMStopMonitoring(
rpcConnectionConfig,
providerMode,
);
if (result?.success === false) {
throw new Error(String(result?.message || "停止监控失败"));
}
setSession((current) => ({ ...current, running: false }));
setPollSeed((current) => current + 1);
} catch (stopError: any) {
setError(stopError?.message || "停止监控失败");
} finally {
setActionLoading(false);
}
};
return (
<div
className="jvm-monitoring-dashboard-scroll-shell"
data-jvm-monitoring-dashboard-scroll-shell="true"
style={{
height: "100%",
minHeight: 0,
overflowY: "auto",
overflowX: "hidden",
padding: 20,
display: "grid",
gap: 16,
alignContent: "start",
background: darkMode ? "#141414" : "#f5f7fb",
}}
>
<Card variant="borderless" style={{ borderRadius: 12 }}>
<Space
direction="vertical"
size={12}
style={{ width: "100%", alignItems: "stretch" }}
>
<Space size={12} wrap style={{ justifyContent: "space-between" }}>
<div>
<Title level={3} style={{ margin: 0 }}>
<DashboardOutlined style={{ color: "#1677ff", marginRight: 8 }} />
JVM
</Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
<Text strong>{connection.name}</Text>
<Text type="secondary">
{" "}
· {connection.config.host}:{connection.config.port}
</Text>
</Paragraph>
</div>
<Space wrap>
<Tag color={modeMeta.color} style={{ marginInlineEnd: 0 }}>
{modeMeta.label}
</Tag>
{session.running ? (
<Tag color="green"></Tag>
) : (
<Tag></Tag>
)}
<Button
icon={<ReloadOutlined />}
onClick={() => setPollSeed((current) => current + 1)}
>
</Button>
{session.running ? (
<Button
danger
type="primary"
icon={<PauseCircleOutlined />}
loading={actionLoading}
onClick={() => void handleStop()}
>
</Button>
) : (
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={actionLoading}
onClick={() => void handleStart()}
>
</Button>
)}
</Space>
</Space>
{(session.missingMetrics?.length || session.providerWarnings?.length) ? (
<Alert
type="warning"
showIcon
message="监控能力存在降级"
description={availabilityText}
/>
) : null}
{error ? <Alert type="error" showIcon message={error} /> : null}
</Space>
</Card>
{loading && emptyState ? (
<div style={{ display: "flex", justifyContent: "center", padding: "24px 0" }}>
<Spin />
</div>
) : null}
{emptyState ? (
<div
data-jvm-monitoring-content-stack="true"
style={{
display: "grid",
gap: 24,
alignItems: "start",
}}
>
<Card variant="borderless" style={{ borderRadius: 12 }}>
<Empty
description="当前尚未开始持续监控"
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
<Paragraph type="secondary" style={{ maxWidth: 520, margin: "0 auto 16px" }}>
GoNavi
</Paragraph>
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={actionLoading}
onClick={() => void handleStart()}
>
</Button>
</Empty>
</Card>
<JVMMonitoringCharts
points={session.points || []}
session={session}
darkMode={darkMode}
/>
</div>
) : (
<div
data-jvm-monitoring-content-stack="true"
style={{
display: "grid",
gap: 24,
alignItems: "start",
}}
>
<JVMMonitoringStatusCards
latestPoint={latestPoint}
session={session}
darkMode={darkMode}
/>
<JVMMonitoringCharts
points={session.points || []}
session={session}
darkMode={darkMode}
/>
<JVMMonitoringDetailPanel
session={session}
latestPoint={latestPoint}
darkMode={darkMode}
/>
</div>
)}
</div>
);
};
export default JVMMonitoringDashboard;

View File

@@ -0,0 +1,65 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import JVMOverview from "./JVMOverview";
vi.mock("../../wailsjs/go/app/App", () => ({
JVMProbeCapabilities: vi.fn(),
}));
vi.mock("../store", () => ({
useStore: (selector: (state: any) => any) =>
selector({
connections: [
{
id: "conn-jvm-1",
name: "orders-jvm",
config: {
host: "localhost",
port: 10990,
jvm: {
preferredMode: "jmx",
allowedModes: ["jmx", "endpoint", "agent"],
readOnly: true,
environment: "dev",
endpoint: {
enabled: true,
baseUrl: "http://localhost:8080/actuator",
},
agent: {
enabled: true,
baseUrl: "http://localhost:8563",
},
},
},
},
],
theme: "light",
}),
}));
describe("JVMOverview", () => {
it("renders a unified JVM workspace overview shell", () => {
const markup = renderToStaticMarkup(
<JVMOverview
tab={{
id: "tab-jvm-overview",
type: "jvm-overview",
title: "[orders-jvm] JVM 概览",
connectionId: "conn-jvm-1",
providerMode: "jmx",
} as any}
/>,
);
expect(markup).toContain('data-jvm-workspace-shell="true"');
expect(markup).toContain('data-jvm-workspace-hero="true"');
expect(markup).toContain("JVM 运行时概览");
expect(markup).toContain("连接摘要");
expect(markup).toContain("模式能力");
expect(markup).toContain("JMX 地址");
expect(markup).toContain("Endpoint");
expect(markup).toContain("Agent");
});
});

View File

@@ -0,0 +1,239 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Alert,
Card,
Descriptions,
Empty,
Skeleton,
Space,
Tag,
Typography,
} from "antd";
import { useStore } from "../store";
import { JVMProbeCapabilities } from "../../wailsjs/go/app/App";
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
import { resolveJVMModeMeta } from "../utils/jvmRuntimePresentation";
import type { JVMCapability, TabData } from "../types";
import JVMModeBadge from "./jvm/JVMModeBadge";
import {
getJVMWorkspaceCardStyle,
JVMWorkspaceHero,
JVMWorkspaceShell,
} from "./jvm/JVMWorkspaceLayout";
const { Text } = Typography;
const DESCRIPTION_STYLES = { label: { width: 120 } } as const;
type JVMOverviewProps = {
tab: TabData;
};
const JVMOverview: React.FC<JVMOverviewProps> = ({ tab }) => {
const connection = useStore((state) =>
state.connections.find((item) => item.id === tab.connectionId),
);
const theme = useStore((state) => state.theme);
const darkMode = theme === "dark";
const providerMode =
tab.providerMode || connection?.config.jvm?.preferredMode || "jmx";
const readOnly = connection?.config.jvm?.readOnly !== false;
const allowedModes = connection?.config.jvm?.allowedModes || [];
const [capabilities, setCapabilities] = useState<JVMCapability[]>([]);
const [capabilityLoading, setCapabilityLoading] = useState(true);
const [capabilityError, setCapabilityError] = useState("");
const endpointSummary = useMemo(() => {
if (!connection?.config.jvm?.endpoint) {
return "";
}
const endpoint = connection.config.jvm.endpoint;
if (!endpoint.enabled && !endpoint.baseUrl) {
return "";
}
return endpoint.baseUrl || "已启用";
}, [connection]);
const agentSummary = useMemo(() => {
if (!connection?.config.jvm?.agent) {
return "";
}
const agent = connection.config.jvm.agent;
if (!agent.enabled && !agent.baseUrl) {
return "";
}
return agent.baseUrl || "已启用";
}, [connection]);
const allowedModeSummary = useMemo(() => {
const items = allowedModes.length > 0 ? allowedModes : ["jmx"];
return items.map((item) => resolveJVMModeMeta(item).label).join("、");
}, [allowedModes]);
useEffect(() => {
if (!connection) {
setCapabilities([]);
setCapabilityError("连接不存在或已被删除");
setCapabilityLoading(false);
return;
}
let cancelled = false;
const loadCapabilities = async () => {
setCapabilityLoading(true);
setCapabilityError("");
try {
const result = await JVMProbeCapabilities(
buildRpcConnectionConfig(connection.config, { database: "" }) as any,
);
if (cancelled) {
return;
}
if (result?.success === false) {
setCapabilities([]);
setCapabilityError(
String(result?.message || "读取 JVM 模式能力失败"),
);
return;
}
setCapabilities(
Array.isArray(result?.data) ? (result.data as JVMCapability[]) : [],
);
} catch (error: any) {
if (!cancelled) {
setCapabilities([]);
setCapabilityError(error?.message || "读取 JVM 模式能力失败");
}
} finally {
if (!cancelled) {
setCapabilityLoading(false);
}
}
};
void loadCapabilities();
return () => {
cancelled = true;
};
}, [connection]);
if (!connection) {
return (
<Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />
);
}
const jmxHost = connection.config.jvm?.jmx?.host || connection.config.host;
const jmxPort = connection.config.jvm?.jmx?.port || connection.config.port;
const cardStyle = getJVMWorkspaceCardStyle(darkMode);
return (
<JVMWorkspaceShell darkMode={darkMode}>
<JVMWorkspaceHero
darkMode={darkMode}
eyebrow="JVM Runtime"
title="JVM 运行时概览"
description={
<>
<Text strong>{connection.name}</Text>
<Text type="secondary">
{" "}
· {connection.config.host}:{connection.config.port}
</Text>
</>
}
badges={
<>
<JVMModeBadge mode={providerMode} />
<Tag color={readOnly ? "blue" : "red"}>
{readOnly ? "只读连接" : "可写连接"}
</Tag>
<Tag>{connection.config.jvm?.environment || "dev"}</Tag>
</>
}
/>
<Card title="连接摘要" variant="borderless" style={cardStyle}>
<Descriptions column={1} size="small" styles={DESCRIPTION_STYLES}>
<Descriptions.Item label="当前模式">
{resolveJVMModeMeta(providerMode).label}
</Descriptions.Item>
<Descriptions.Item label="允许模式">
{allowedModeSummary}
</Descriptions.Item>
<Descriptions.Item label="JMX 地址">{`${jmxHost}:${jmxPort}`}</Descriptions.Item>
<Descriptions.Item label="Endpoint">
{endpointSummary || "未配置"}
</Descriptions.Item>
<Descriptions.Item label="Agent">
{agentSummary || "未配置"}
</Descriptions.Item>
<Descriptions.Item label="资源浏览">
{"通过侧边栏展开模式节点后懒加载"}
</Descriptions.Item>
</Descriptions>
</Card>
<Card title="模式能力" variant="borderless" style={cardStyle}>
{capabilityLoading ? (
<Skeleton active paragraph={{ rows: 3 }} />
) : capabilityError ? (
<Alert
type="error"
showIcon
message="读取 JVM 模式能力失败"
description={
<span style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{capabilityError}
</span>
}
/>
) : capabilities.length === 0 ? (
<Empty description="暂无模式能力数据" />
) : (
<Space direction="vertical" size={12} style={{ width: "100%" }}>
{capabilities.map((capability) => (
<div
key={capability.mode}
style={{
border: "1px solid rgba(5, 5, 5, 0.08)",
borderRadius: 8,
padding: 12,
}}
>
<Space size={8} wrap>
<JVMModeBadge mode={capability.mode} />
<Tag color={capability.canBrowse ? "green" : "default"}>
{capability.canBrowse ? "可浏览" : "不可浏览"}
</Tag>
<Tag color={capability.canWrite ? "red" : "blue"}>
{capability.canWrite ? "可写" : "只读"}
</Tag>
<Tag color={capability.canPreview ? "gold" : "default"}>
{capability.canPreview ? "支持预览" : "不支持预览"}
</Tag>
</Space>
{capability.reason ? (
<Text
type="secondary"
style={{
display: "block",
marginTop: 8,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{capability.reason}
</Text>
) : null}
</div>
))}
</Space>
)}
</Card>
</JVMWorkspaceShell>
);
};
export default JVMOverview;

View File

@@ -0,0 +1,563 @@
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import JVMResourceBrowser from "./JVMResourceBrowser";
import type { JVMValueSnapshot } from "../types";
const storeState = vi.hoisted(() => ({
connections: [
{
id: "conn-jvm-writable",
name: "orders-jvm",
config: {
host: "127.0.0.1",
user: "jmx-user",
port: 9010,
type: "jvm",
jvm: {
preferredMode: "jmx",
readOnly: false,
jmx: {
password: "initial-jmx-secret",
},
},
},
},
],
addTab: vi.fn(),
aiPanelVisible: false,
setAIPanelVisible: vi.fn(),
theme: "light",
}));
const backendApp = vi.hoisted(() => ({
JVMGetValue: vi.fn(),
JVMPreviewChange: vi.fn(),
JVMApplyChange: vi.fn(),
}));
vi.mock("@monaco-editor/react", () => ({
default: ({ value }: { value?: string }) => <pre>{value}</pre>,
}));
vi.mock("@ant-design/icons", () => ({
FileSearchOutlined: () => <span />,
ReloadOutlined: () => <span />,
RobotOutlined: () => <span />,
}));
vi.mock("antd", () => {
const Text = ({ children }: any) => <span>{children}</span>;
const Button = ({ children, disabled, loading, onClick, type, ...rest }: any) => (
<button
type="button"
data-button-type={type}
disabled={disabled || loading}
onClick={onClick}
{...rest}
>
{children}
</button>
);
const Card = ({ children, title }: any) => (
<section>
<h2>{title}</h2>
{children}
</section>
);
const Descriptions: any = ({ children }: any) => <dl>{children}</dl>;
Descriptions.Item = ({ children, label }: any) => (
<div>
<dt>{label}</dt>
<dd>{children}</dd>
</div>
);
const Input: any = ({ value, onChange, placeholder }: any) => (
<input value={value} onChange={onChange} placeholder={placeholder} />
);
Input.TextArea = ({ value, onChange }: any) => (
<textarea value={value} onChange={onChange} />
);
return {
Alert: ({ message }: any) => <div role="alert">{message}</div>,
Button,
Card,
Descriptions,
Empty: ({ description }: any) => <div>{description}</div>,
Input,
Skeleton: () => <div>loading</div>,
Space: ({ children }: any) => <div>{children}</div>,
Tag: ({ children }: any) => <span>{children}</span>,
Typography: { Text },
};
});
vi.mock("../store", () => {
const useStore = (selector: (state: typeof storeState) => any) => selector(storeState);
useStore.getState = () => storeState;
return { useStore };
});
vi.mock("./jvm/JVMModeBadge", () => ({
default: ({ mode }: { mode: string }) => <span>{mode}</span>,
}));
vi.mock("./jvm/JVMWorkspaceLayout", () => ({
getJVMWorkspaceCardStyle: () => ({}),
JVMWorkspaceHero: ({ actions, badges, description, title }: any) => (
<header>
<h1>{title}</h1>
{description}
{badges}
{actions}
</header>
),
JVMWorkspaceShell: ({ children }: any) => <main>{children}</main>,
}));
vi.mock("./jvm/JVMChangePreviewModal", () => ({
default: ({ open, onConfirm }: any) =>
open ? <button type="button" onClick={onConfirm}></button> : null,
}));
const writableTab = {
id: "tab-jvm-resource",
type: "jvm-resource",
title: "[orders-jvm] JVM 资源",
connectionId: "conn-jvm-writable",
providerMode: "jmx",
resourcePath: "jmx:/attribute/app/Mode",
resourceKind: "attribute",
} as any;
const textContent = (node: any): string =>
(node.children || [])
.map((item: any) => (typeof item === "string" ? item : textContent(item)))
.join("");
const findButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === "button" && textContent(node).includes(text))[0];
const waitForEffects = async () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
};
describe("JVMResourceBrowser interactions", () => {
beforeEach(() => {
storeState.connections = [
{
id: "conn-jvm-writable",
name: "orders-jvm",
config: {
host: "127.0.0.1",
user: "jmx-user",
port: 9010,
type: "jvm",
jvm: {
preferredMode: "jmx",
readOnly: false,
jmx: {
password: "initial-jmx-secret",
},
},
},
},
];
const snapshot: JVMValueSnapshot = {
resourceId: "jmx:/attribute/app/Mode",
kind: "attribute",
format: "string",
version: "v1",
value: "cold",
supportedActions: [
{
action: "set",
label: "设置属性",
payloadExample: { value: "warm" },
},
],
};
backendApp.JVMGetValue.mockResolvedValue({ success: true, data: snapshot });
backendApp.JVMPreviewChange.mockResolvedValue({
allowed: true,
requiresConfirmation: true,
confirmationToken: "token-from-preview",
summary: "设置 Mode",
riskLevel: "high",
before: snapshot,
after: { ...snapshot, value: "warm", version: "v2" },
});
backendApp.JVMApplyChange.mockResolvedValue({
success: true,
data: {
status: "applied",
updatedValue: { ...snapshot, value: "warm", version: "v2" },
},
});
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
go: {
app: {
App: backendApp,
},
},
});
});
afterEach(() => {
backendApp.JVMGetValue.mockReset();
backendApp.JVMPreviewChange.mockReset();
backendApp.JVMApplyChange.mockReset();
vi.unstubAllGlobals();
});
it("applies the latest successful preview request even when the draft is edited afterward", async () => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
});
const payloadEditor = () => renderer!.root.findByType("textarea");
await act(async () => {
payloadEditor().props.onChange({ target: { value: '{"value":"previewed"}' } });
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await waitForEffects();
await act(async () => {
payloadEditor().props.onChange({ target: { value: '{"value":"edited-after-preview"}' } });
});
await act(async () => {
findButton(renderer!, "确认执行").props.onClick();
});
await waitForEffects();
expect(backendApp.JVMApplyChange).toHaveBeenCalledTimes(1);
expect(backendApp.JVMApplyChange.mock.calls[0][0]).toBe(
backendApp.JVMPreviewChange.mock.calls[0][0],
);
expect(backendApp.JVMApplyChange.mock.calls[0][1]).toMatchObject({
action: "set",
confirmationToken: "token-from-preview",
payload: { value: "previewed" },
});
});
it("does not let a stale snapshot resource id override the current resource preview", async () => {
backendApp.JVMGetValue.mockResolvedValueOnce({
success: true,
data: {
resourceId: "jmx:/attribute/app/Mode",
kind: "attribute",
format: "string",
version: "v1",
value: "cold",
supportedActions: [
{
action: "set",
label: "设置属性",
payloadExample: { value: "warm" },
},
],
} as JVMValueSnapshot,
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
await act(async () => {
renderer!.update(
<JVMResourceBrowser
tab={{
...writableTab,
resourcePath: "jmx:/attribute/app/OtherMode",
}}
/>,
);
});
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
renderer!.root.findByType("textarea").props.onChange({
target: { value: '{"value":"previewed"}' },
});
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await waitForEffects();
expect(backendApp.JVMPreviewChange.mock.calls[backendApp.JVMPreviewChange.mock.calls.length - 1]?.[1]).toMatchObject({
resourceId: "jmx:/attribute/app/OtherMode",
});
});
it("ignores stale preview responses after the resource context changes", async () => {
let resolvePreview: (value: any) => void = () => {};
backendApp.JVMPreviewChange.mockReturnValueOnce(
new Promise((resolve) => {
resolvePreview = resolve;
}),
);
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
renderer!.root.findByType("textarea").props.onChange({
target: { value: '{"value":"previewed"}' },
});
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await act(async () => {
renderer!.update(
<JVMResourceBrowser
tab={{
...writableTab,
resourcePath: "jmx:/attribute/app/OtherMode",
}}
/>,
);
resolvePreview({
allowed: true,
requiresConfirmation: true,
confirmationToken: "stale-token",
summary: "旧预览",
riskLevel: "high",
before: {
resourceId: "jmx:/attribute/app/Mode",
kind: "attribute",
format: "string",
value: "cold",
},
after: {
resourceId: "jmx:/attribute/app/Mode",
kind: "attribute",
format: "string",
value: "warm",
},
});
});
await waitForEffects();
expect(findButton(renderer!, "确认执行")).toBeUndefined();
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
});
it("rejects confirming a preview after the resource context changes", async () => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
renderer!.root.findByType("textarea").props.onChange({
target: { value: '{"value":"previewed"}' },
});
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await waitForEffects();
await act(async () => {
renderer!.update(
<JVMResourceBrowser
tab={{
...writableTab,
resourcePath: "jmx:/attribute/app/OtherMode",
}}
/>,
);
findButton(renderer!, "确认执行").props.onClick();
});
await waitForEffects();
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
});
it("rejects confirming a preview after the connection config changes", async () => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
renderer!.root.findByType("textarea").props.onChange({
target: { value: '{"value":"previewed"}' },
});
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await waitForEffects();
storeState.connections = [
{
...storeState.connections[0],
config: {
...storeState.connections[0].config,
jvm: {
...storeState.connections[0].config.jvm,
readOnly: true,
},
},
},
];
await act(async () => {
renderer!.update(<JVMResourceBrowser tab={writableTab} />);
});
const confirmButton = findButton(renderer!, "确认执行");
if (confirmButton) {
await act(async () => {
confirmButton.props.onClick();
});
}
await waitForEffects();
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
});
it("rejects confirming a preview after JVM credentials change", async () => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
renderer!.root.findByType("textarea").props.onChange({
target: { value: '{"value":"previewed"}' },
});
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await waitForEffects();
storeState.connections = [
{
...storeState.connections[0],
config: {
...storeState.connections[0].config,
jvm: {
...storeState.connections[0].config.jvm,
jmx: {
...storeState.connections[0].config.jvm.jmx,
password: "rotated-jmx-secret",
},
},
},
},
];
await act(async () => {
renderer!.update(<JVMResourceBrowser tab={writableTab} />);
});
const confirmButton = findButton(renderer!, "确认执行");
if (confirmButton) {
await act(async () => {
confirmButton.props.onClick();
});
}
await waitForEffects();
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
});
it("does not seed sensitive payload examples into the draft editor", async () => {
backendApp.JVMGetValue.mockResolvedValueOnce({
success: true,
data: {
resourceId: "jmx:/attribute/app/Password",
kind: "attribute",
format: "string",
version: "v1",
value: "secret-token",
sensitive: true,
supportedActions: [
{
action: "set",
label: "设置属性",
payloadExample: { value: "secret-token" },
},
],
} as JVMValueSnapshot,
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<JVMResourceBrowser
tab={{
...writableTab,
resourcePath: "jmx:/attribute/app/Password",
}}
/>,
);
});
await waitForEffects();
expect(renderer!.root.findByType("textarea").props.value).not.toContain("secret-token");
});
});

View File

@@ -0,0 +1,118 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import JVMResourceBrowser from './JVMResourceBrowser';
vi.mock('@monaco-editor/react', () => ({
default: ({ language, value }: { language?: string; value?: string }) => (
<div data-monaco-editor-mock="true" data-language={language}>
{value}
</div>
),
}));
vi.mock('../store', () => ({
useStore: (selector: (state: any) => any) => selector({
connections: [
{
id: 'conn-jvm-1',
name: 'localhost',
config: {
host: 'localhost',
jvm: {
preferredMode: 'jmx',
readOnly: true,
},
},
},
{
id: 'conn-jvm-2',
name: 'writable-jvm',
config: {
host: 'localhost',
jvm: {
preferredMode: 'jmx',
readOnly: false,
},
},
},
],
addTab: vi.fn(),
aiPanelVisible: false,
setAIPanelVisible: vi.fn(),
}),
}));
vi.mock('./jvm/JVMModeBadge', () => ({
default: ({ mode }: { mode: string }) => <span>{mode}</span>,
}));
vi.mock('./jvm/JVMChangePreviewModal', () => ({
default: () => null,
}));
describe('JVMResourceBrowser layout', () => {
it('renders a dedicated vertical scroll shell for tall snapshot content', () => {
const markup = renderToStaticMarkup(
<JVMResourceBrowser
tab={{
id: 'tab-jvm-resource-1',
type: 'jvm-resource',
title: '[localhost] JVM 资源',
connectionId: 'conn-jvm-1',
providerMode: 'jmx',
resourcePath: 'jmx:/mbean/com.alibaba.druid:type=DruidDriver',
resourceKind: 'mbean',
} as any}
/>,
);
expect(markup).toContain('data-jvm-resource-browser-scroll-shell="true"');
expect(markup).toContain('data-jvm-workspace-shell="true"');
expect(markup).toContain('data-jvm-workspace-hero="true"');
expect(markup).toContain('data-jvm-resource-workbench="true"');
expect(markup).toContain('height:100%');
expect(markup).toContain('overflow-y:auto');
expect(markup).toContain('grid-template-columns:minmax(0, 1fr) minmax(360px, 440px)');
});
it('shows the draft action field with a Chinese label', () => {
const markup = renderToStaticMarkup(
<JVMResourceBrowser
tab={{
id: 'tab-jvm-resource-2',
type: 'jvm-resource',
title: '[localhost] JVM 资源',
connectionId: 'conn-jvm-2',
providerMode: 'jmx',
resourcePath: 'jmx:/mbean/com.alibaba.druid:type=DruidDriver',
resourceKind: 'mbean',
} as any}
/>,
);
expect(markup).toContain('动作');
expect(markup).not.toContain('>Action<');
});
it('hides the change draft form entirely for read-only JVM connections', () => {
const markup = renderToStaticMarkup(
<JVMResourceBrowser
tab={{
id: 'tab-jvm-resource-3',
type: 'jvm-resource',
title: '[localhost] JVM 资源',
connectionId: 'conn-jvm-1',
providerMode: 'jmx',
resourcePath: 'jmx:/mbean/com.alibaba.druid:type=DruidDriver',
resourceKind: 'mbean',
} as any}
/>,
);
expect(markup).not.toContain('变更草稿');
expect(markup).not.toContain('预览变更');
expect(markup).not.toContain('Payload(JSON)');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,547 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SavedQuery, TabData } from '../types';
import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
import QueryEditor from './QueryEditor';
const storeState = vi.hoisted(() => ({
connections: [
{
id: 'conn-1',
name: 'local',
config: {
type: 'mysql',
host: '127.0.0.1',
port: 3306,
user: 'root',
password: '',
database: 'main',
},
},
],
addSqlLog: vi.fn(),
addTab: vi.fn(),
savedQueries: [] as SavedQuery[],
saveQuery: vi.fn(),
theme: 'light',
sqlFormatOptions: { keywordCase: 'upper' as const },
setSqlFormatOptions: vi.fn(),
queryOptions: { maxRows: 5000 },
setQueryOptions: vi.fn(),
shortcutOptions: {
runQuery: { enabled: false, combo: '' },
},
activeTabId: 'tab-1',
aiPanelVisible: false,
setAIPanelVisible: vi.fn(),
}));
const backendApp = vi.hoisted(() => ({
DBQueryWithCancel: vi.fn(),
DBQueryMulti: vi.fn(),
DBGetTables: vi.fn(),
DBGetAllColumns: vi.fn(),
DBGetDatabases: vi.fn(),
DBGetColumns: vi.fn(),
DBGetIndexes: vi.fn(),
CancelQuery: vi.fn(),
GenerateQueryID: vi.fn(),
WriteSQLFile: vi.fn(),
}));
const messageApi = vi.hoisted(() => ({
error: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
}));
const dataGridState = vi.hoisted(() => ({
latestProps: null as any,
}));
const editorState = vi.hoisted(() => {
const state = {
value: '',
editor: null as any,
};
state.editor = {
getValue: vi.fn(() => state.value),
setValue: vi.fn((value: string) => {
state.value = value;
}),
getModel: vi.fn(() => ({
getValue: () => state.value,
setValue: (value: string) => {
state.value = value;
},
getValueInRange: () => '',
getLineContent: () => '',
getWordUntilPosition: () => ({ startColumn: 1, endColumn: 1 }),
})),
getSelection: vi.fn(() => null),
addAction: vi.fn(),
onDidChangeModelContent: vi.fn(() => ({ dispose: vi.fn() })),
hasTextFocus: vi.fn(() => true),
};
return state;
});
vi.mock('../store', () => {
const useStore = Object.assign(
(selector: (state: typeof storeState) => any) => selector(storeState),
{ getState: () => storeState },
);
return { useStore };
});
vi.mock('../../wailsjs/go/app/App', () => backendApp);
vi.mock('../utils/autoFetchVisibility', () => ({
useAutoFetchVisibility: () => false,
}));
vi.mock('@monaco-editor/react', () => ({
default: ({ defaultValue, onMount }: any) => {
React.useEffect(() => {
editorState.value = String(defaultValue || '');
onMount?.(editorState.editor, {
editor: { setTheme: vi.fn() },
languages: {
CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 },
registerCompletionItemProvider: vi.fn(),
},
});
}, []);
return <textarea data-editor value={editorState.value} readOnly />;
},
}));
vi.mock('./DataGrid', () => ({
default: (props: any) => {
dataGridState.latestProps = props;
return <div data-grid="true" />;
},
GONAVI_ROW_KEY: '__gonavi_row_key__',
}));
vi.mock('@ant-design/icons', () => {
const Icon = () => <span />;
return {
PlayCircleOutlined: Icon,
SaveOutlined: Icon,
FormatPainterOutlined: Icon,
SettingOutlined: Icon,
CloseOutlined: Icon,
StopOutlined: Icon,
RobotOutlined: Icon,
};
});
vi.mock('antd', () => {
const Button: any = ({ children, disabled, loading, onClick, ...rest }: any) => (
<button type="button" disabled={disabled || loading} onClick={onClick} {...rest}>
{children}
</button>
);
Button.Group = ({ children }: any) => <div>{children}</div>;
const Form: any = ({ children }: any) => <form>{children}</form>;
Form.Item = ({ children }: any) => <>{children}</>;
Form.useForm = () => [{ setFieldsValue: vi.fn(), validateFields: vi.fn(() => Promise.resolve({ name: '查询' })) }];
return {
Button,
message: messageApi,
Modal: ({ children, open }: any) => (open ? <section>{children}</section> : null),
Input: ({ value, onChange, placeholder }: any) => <input value={value} onChange={onChange} placeholder={placeholder} />,
Form,
Dropdown: ({ children }: any) => <>{children}</>,
Tooltip: ({ children }: any) => <>{children}</>,
Select: () => null,
Tabs: ({ items }: any) => <div>{items?.[0]?.children}</div>,
};
});
const textContent = (node: any): string =>
(node.children || [])
.map((item: any) => (typeof item === 'string' ? item : textContent(item)))
.join('');
const findButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0];
const createTab = (overrides: Partial<TabData> = {}): TabData => ({
id: 'tab-1',
title: 'query.sql',
type: 'query',
connectionId: 'conn-1',
dbName: 'main',
query: 'select 1;',
...overrides,
});
describe('QueryEditor external SQL save', () => {
beforeEach(() => {
vi.stubGlobal('window', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
});
storeState.addTab.mockReset();
storeState.saveQuery.mockReset();
storeState.savedQueries = [];
storeState.activeTabId = 'tab-1';
messageApi.success.mockReset();
messageApi.error.mockReset();
messageApi.warning.mockReset();
backendApp.WriteSQLFile.mockResolvedValue({ success: true });
backendApp.DBQueryMulti.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
backendApp.GenerateQueryID.mockResolvedValue('query-1');
storeState.connections[0].config.type = 'mysql';
storeState.connections[0].config.database = 'main';
dataGridState.latestProps = null;
editorState.value = '';
editorState.editor.getValue.mockClear();
editorState.editor.setValue.mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('writes external SQL file tabs back to disk without creating saved queries', async () => {
let renderer: ReactTestRenderer;
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ filePath })} />);
});
editorState.value = 'select 2;';
await act(async () => {
await findButton(renderer!, '保存').props.onClick();
});
expect(backendApp.WriteSQLFile).toHaveBeenCalledWith(filePath, 'select 2;');
expect(storeState.saveQuery).not.toHaveBeenCalled();
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
filePath,
query: 'select 2;',
savedQueryId: undefined,
}));
expect(messageApi.success).toHaveBeenCalledWith('SQL 文件已保存!');
});
it('does not create saved queries when external SQL file writes fail', async () => {
let renderer: ReactTestRenderer;
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
backendApp.WriteSQLFile.mockResolvedValueOnce({ success: false, message: '磁盘只读' });
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ filePath })} />);
});
editorState.value = 'select 4;';
await act(async () => {
await findButton(renderer!, '保存').props.onClick();
});
expect(backendApp.WriteSQLFile).toHaveBeenCalledWith(filePath, 'select 4;');
expect(storeState.saveQuery).not.toHaveBeenCalled();
expect(storeState.addTab).not.toHaveBeenCalled();
expect(messageApi.error).toHaveBeenCalledWith('保存 SQL 文件失败: 磁盘只读');
});
it('keeps saved query quick-save behavior for non-file tabs', async () => {
storeState.savedQueries = [
{
id: 'saved-1',
name: '常用查询',
sql: 'select 1;',
connectionId: 'conn-1',
dbName: 'main',
createdAt: 100,
},
];
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ savedQueryId: 'saved-1' })} />);
});
editorState.value = 'select 3;';
await act(async () => {
findButton(renderer!, '保存').props.onClick();
});
expect(backendApp.WriteSQLFile).not.toHaveBeenCalled();
expect(storeState.saveQuery).toHaveBeenCalledWith(expect.objectContaining({
id: 'saved-1',
name: '常用查询',
sql: 'select 3;',
connectionId: 'conn-1',
dbName: 'main',
createdAt: 100,
}));
});
it('automatically appends hidden primary key locator columns for editable query results', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME', '__gonavi_locator_1_ID'], rows: [{ NAME: 'old-name', __gonavi_locator_1_ID: 7 }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'ANONYMOUS', query: 'SELECT NAME FROM MYCIMLED.EDC_LOG' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBe('MYCIMLED.EDC_LOG');
expect(dataGridState.latestProps?.pkColumns).toEqual(['ID']);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'primary-key',
columns: ['ID'],
valueColumns: ['__gonavi_locator_1_ID'],
hiddenColumns: ['__gonavi_locator_1_ID'],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(dataGridState.latestProps?.resultSql).toBe('SELECT NAME FROM MYCIMLED.EDC_LOG');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('"ID" AS "__gonavi_locator_1_ID"');
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('uses a unique index locator for query results without primary keys', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME', '__gonavi_locator_1_EMAIL'], rows: [{ NAME: 'old-name', __gonavi_locator_1_EMAIL: 'a@example.com' }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'EMAIL', key: '' }, { name: 'NAME', key: '' }],
});
backendApp.DBGetIndexes.mockResolvedValueOnce({
success: true,
data: [{ name: 'UK_EMAIL', columnName: 'EMAIL', nonUnique: 0, seqInIndex: 1, indexType: 'BTREE' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'ANONYMOUS', query: 'SELECT NAME FROM MYCIMLED.EDC_LOG' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'unique-key',
columns: ['EMAIL'],
valueColumns: ['__gonavi_locator_1_EMAIL'],
hiddenColumns: ['__gonavi_locator_1_EMAIL'],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('"EMAIL" AS "__gonavi_locator_1_EMAIL"');
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('uses hidden Oracle ROWID for query results without primary or unique keys', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME', ORACLE_ROWID_LOCATOR_COLUMN], rows: [{ NAME: 'old-name', [ORACLE_ROWID_LOCATOR_COLUMN]: 'AAAA' }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'NAME', key: '' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'ANONYMOUS', query: 'SELECT NAME FROM MYCIMLED.EDC_LOG' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'oracle-rowid',
columns: ['ROWID'],
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain(`ROWID AS "${ORACLE_ROWID_LOCATOR_COLUMN}"`);
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('keeps non-Oracle query results read-only when no safe locator exists', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME'], rows: [{ NAME: 'old-name' }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'NAME', key: '' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: 'SELECT NAME FROM users' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBe('users');
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'none',
readOnly: true,
reason: '未检测到主键或可用唯一索引,无法安全提交修改。',
});
expect(dataGridState.latestProps?.readOnly).toBe(true);
expect(messageApi.warning).toHaveBeenCalledWith('查询结果保持只读main.users 未检测到主键或可用唯一索引,无法安全提交修改。');
});
it('allows editable table columns while leaving expression columns out of commits', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{
columns: ['DISPLAY_NAME', 'NAME_UPPER', '__gonavi_locator_1_ID'],
rows: [{ DISPLAY_NAME: 'old-name', NAME_UPPER: 'OLD-NAME', __gonavi_locator_1_ID: 7 }],
}],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'SELECT NAME AS DISPLAY_NAME, UPPER(NAME) AS NAME_UPPER FROM users',
})} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBe('users');
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'primary-key',
columns: ['ID'],
valueColumns: ['__gonavi_locator_1_ID'],
hiddenColumns: ['__gonavi_locator_1_ID'],
writableColumns: {
DISPLAY_NAME: 'NAME',
},
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('`ID` AS `__gonavi_locator_1_ID`');
expect(messageApi.warning).not.toHaveBeenCalled();
});
it.each([
'mysql',
'mariadb',
'oceanbase',
'diros',
'sphinx',
'postgres',
'kingbase',
'highgo',
'vastbase',
'opengauss',
'sqlserver',
'sqlite',
'duckdb',
'oracle',
'dameng',
'tdengine',
'clickhouse',
])(
'keeps aggregate query results silently read-only for %s',
async (dbType) => {
storeState.connections[0].config.type = dbType;
storeState.connections[0].config.database = dbType === 'oracle' || dbType === 'dameng' ? 'APP' : 'main';
const forceReadOnlyQueryResult = dbType === 'tdengine' || dbType === 'clickhouse';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['COUNT'], rows: [{ COUNT: 1 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: storeState.connections[0].config.database,
query: 'SELECT count(1) FROM users',
})} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBe(forceReadOnlyQueryResult ? undefined : 'users');
expect(dataGridState.latestProps?.editLocator).toBeUndefined();
expect(dataGridState.latestProps?.readOnly).toBe(true);
expect(backendApp.DBGetColumns).not.toHaveBeenCalled();
expect(backendApp.DBGetIndexes).not.toHaveBeenCalled();
expect(messageApi.warning).not.toHaveBeenCalled();
},
);
});

View File

@@ -4,14 +4,21 @@ import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Sele
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined, RobotOutlined } from '@ant-design/icons';
import { format } from 'sql-formatter';
import { v4 as uuidv4 } from 'uuid';
import { TabData, ColumnDefinition } from '../types';
import { TabData, ColumnDefinition, IndexDefinition } from '../types';
import { useStore } from '../store';
import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID } from '../../wailsjs/go/app/App';
import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, DBGetIndexes, CancelQuery, GenerateQueryID, WriteSQLFile } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { convertMongoShellToJsonCommand } from '../utils/mongodb';
import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from '../utils/mongodb';
import { getShortcutDisplay, isEditableElement, isShortcutMatch } from '../utils/shortcuts';
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { isOracleLikeDialect, resolveSqlDialect, resolveSqlFunctions, resolveSqlKeywords } from '../utils/sqlDialect';
import { applyQueryAutoLimit } from '../utils/queryAutoLimit';
import { extractQueryResultTableRef, type QueryResultTableRef } from '../utils/queryResultTable';
import { quoteIdentPart } from '../utils/sql';
import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert';
import { ORACLE_ROWID_LOCATOR_COLUMN, type EditRowLocator } from '../utils/rowLocator';
const SQL_KEYWORDS = [
'SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT',
@@ -184,6 +191,300 @@ let sharedAllColumnsData: {dbName: string, tableName: string, name: string, type
let sharedVisibleDbs: string[] = [];
let sharedColumnsCacheData: Record<string, any[]> = {};
const QUERY_LOCATOR_ALIAS_PREFIX = '__gonavi_locator_';
const buildQueryReadOnlyLocator = (reason: string): EditRowLocator => ({
strategy: 'none',
columns: [],
valueColumns: [],
readOnly: true,
reason,
});
type SimpleSelectInfo = {
selectsAll: boolean;
writableColumns: Record<string, string>;
};
type QueryStatementPlan = {
originalSql: string;
executedSql: string;
tableRef?: QueryResultTableRef;
pkColumns: string[];
editLocator?: EditRowLocator;
warning?: string;
};
const stripQueryIdentifierQuotes = (part: string): string => {
const text = String(part || '').trim();
if (!text) return '';
if ((text.startsWith('`') && text.endsWith('`')) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1).trim();
}
if (text.startsWith('[') && text.endsWith(']')) {
return text.slice(1, -1).trim();
}
return text;
};
const splitTopLevelComma = (text: string): string[] => {
const parts: string[] = [];
let current = '';
let parenDepth = 0;
let inSingle = false;
let inDouble = false;
let inBacktick = false;
let escaped = false;
for (let index = 0; index < text.length; index++) {
const ch = text[index];
if (escaped) {
current += ch;
escaped = false;
continue;
}
if ((inSingle || inDouble) && ch === '\\') {
current += ch;
escaped = true;
continue;
}
if (!inDouble && !inBacktick && ch === "'") {
inSingle = !inSingle;
current += ch;
continue;
}
if (!inSingle && !inBacktick && ch === '"') {
inDouble = !inDouble;
current += ch;
continue;
}
if (!inSingle && !inDouble && ch === '`') {
inBacktick = !inBacktick;
current += ch;
continue;
}
if (!inSingle && !inDouble && !inBacktick) {
if (ch === '(') parenDepth++;
if (ch === ')' && parenDepth > 0) parenDepth--;
if (ch === ',' && parenDepth === 0) {
parts.push(current.trim());
current = '';
continue;
}
}
current += ch;
}
if (current.trim()) parts.push(current.trim());
return parts;
};
const SIMPLE_IDENTIFIER_PATH_RE = /^(?:[`"\[]?[A-Za-z_][\w$]*[`"\]]?\s*\.\s*){0,2}[`"\[]?[A-Za-z_][\w$]*[`"\]]?$/;
const QUERY_ALIAS_RESERVED = new Set([
'where', 'group', 'order', 'having', 'limit', 'fetch', 'offset', 'join', 'left', 'right', 'inner', 'outer', 'on', 'union',
]);
const getLastIdentifierPart = (path: string): string => {
const parts = String(path || '').split('.').map((part) => stripQueryIdentifierQuotes(part.trim())).filter(Boolean);
return parts[parts.length - 1] || '';
};
const resolveSimpleSelectItemColumn = (item: string): { resultName: string; sourceName: string } | 'all' | undefined => {
const text = String(item || '').trim();
if (!text) return undefined;
if (text === '*' || /\.\s*\*$/.test(text)) return 'all';
let expr = text;
let alias = '';
const asMatch = text.match(/^(.*?)\s+AS\s+([`"\[]?[A-Za-z_][\w$]*[`"\]]?)$/i);
if (asMatch) {
expr = asMatch[1].trim();
alias = stripQueryIdentifierQuotes(asMatch[2]);
} else {
const bareAliasMatch = text.match(/^(.*?)\s+([`"\[]?[A-Za-z_][\w$]*[`"\]]?)$/);
if (bareAliasMatch && SIMPLE_IDENTIFIER_PATH_RE.test(bareAliasMatch[1].trim())) {
const candidateAlias = stripQueryIdentifierQuotes(bareAliasMatch[2]);
if (candidateAlias && !QUERY_ALIAS_RESERVED.has(candidateAlias.toLowerCase())) {
expr = bareAliasMatch[1].trim();
alias = candidateAlias;
}
}
}
if (!SIMPLE_IDENTIFIER_PATH_RE.test(expr)) return undefined;
const sourceName = getLastIdentifierPart(expr);
const resultName = alias || sourceName;
return sourceName && resultName ? { resultName, sourceName } : undefined;
};
const parseSimpleSelectInfo = (sql: string): SimpleSelectInfo | undefined => {
const match = String(sql || '').match(/^\s*SELECT\s+([\s\S]+?)\s+FROM\s+/i);
if (!match) return undefined;
const selectList = match[1].trim();
if (!selectList || /^DISTINCT\b/i.test(selectList)) return undefined;
const writableColumns: Record<string, string> = {};
let selectsAll = false;
for (const item of splitTopLevelComma(selectList)) {
const resolved = resolveSimpleSelectItemColumn(item);
if (!resolved) continue;
if (resolved === 'all') {
selectsAll = true;
continue;
}
writableColumns[resolved.resultName] = resolved.sourceName;
}
return { selectsAll, writableColumns };
};
const appendQuerySelectExpressions = (sql: string, expressions: string[]): string => {
if (expressions.length === 0) return sql;
return String(sql || '').replace(
/^(\s*SELECT\s+)([\s\S]+?)(\s+FROM\s+[\s\S]*)$/i,
(_match, prefix, selectList, rest) => `${prefix}${String(selectList).trimEnd()}, ${expressions.join(', ')}${rest}`,
);
};
const findWritableResultColumnForSource = (writableColumns: Record<string, string>, target: string): string | undefined => {
const normalizedTarget = String(target || '').trim().toLowerCase();
return Object.entries(writableColumns || {}).find(([, sourceColumn]) => (
String(sourceColumn || '').trim().toLowerCase() === normalizedTarget
))?.[0];
};
const buildQueryLocatorAlias = (column: string, index: number): string => {
const normalized = String(column || '').trim().replace(/[^A-Za-z0-9_]/g, '_').slice(0, 48) || 'column';
return `${QUERY_LOCATOR_ALIAS_PREFIX}${index}_${normalized}`;
};
const buildQueryLocatorColumnExpression = (dbType: string, column: string, alias: string): string => (
`${quoteIdentPart(dbType, column)} AS ${quoteIdentPart(dbType, alias)}`
);
const buildQueryRowIDExpression = (dbType: string): string => (
`ROWID AS ${quoteIdentPart(dbType, ORACLE_ROWID_LOCATOR_COLUMN)}`
);
const resolveQueryLocatorPlan = async ({
statement,
dbType,
currentDb,
config,
forceReadOnly,
}: {
statement: string;
dbType: string;
currentDb: string;
config: any;
forceReadOnly: boolean;
}): Promise<QueryStatementPlan> => {
const plan: QueryStatementPlan = {
originalSql: statement,
executedSql: statement,
pkColumns: [],
};
if (forceReadOnly) return plan;
const tableRef = extractQueryResultTableRef(statement, dbType, currentDb);
if (!tableRef) return plan;
plan.tableRef = tableRef;
const selectInfo = parseSimpleSelectInfo(statement);
if (!selectInfo) {
// 聚合、函数和表达式结果天然无法安全回写到单行,静默保持只读即可。
return plan;
}
if (!selectInfo.selectsAll && Object.keys(selectInfo.writableColumns).length === 0) {
return plan;
}
try {
const [resCols, resIndexes] = await Promise.all([
DBGetColumns(buildRpcConnectionConfig(config) as any, tableRef.metadataDbName, tableRef.metadataTableName),
DBGetIndexes(buildRpcConnectionConfig(config) as any, tableRef.metadataDbName, tableRef.metadataTableName)
.catch((error: any) => ({ success: false, message: String(error?.message || error || '加载索引失败'), data: [] })),
]);
if (!resCols?.success || !Array.isArray(resCols.data)) {
const reason = `无法加载 ${tableRef.metadataDbName}.${tableRef.metadataTableName} 的主键/唯一索引元数据,无法安全提交修改。`;
plan.editLocator = buildQueryReadOnlyLocator(reason);
plan.warning = `查询结果保持只读:${reason}`;
return plan;
}
const tableColumns = resCols.data as ColumnDefinition[];
const tableColumnNames = tableColumns.map((column) => String(column?.name || '').trim()).filter(Boolean);
const primaryKeys = tableColumns
.filter((column: any) => column?.key === 'PRI')
.map((column: any) => String(column?.name || '').trim())
.filter(Boolean);
const indexes = resIndexes?.success && Array.isArray(resIndexes.data)
? resIndexes.data as IndexDefinition[]
: [];
const writableColumns: Record<string, string> = selectInfo.selectsAll
? Object.fromEntries(tableColumnNames.map((column) => [column, column]))
: {};
Object.entries(selectInfo.writableColumns).forEach(([resultColumn, sourceColumn]) => {
writableColumns[resultColumn] = sourceColumn;
});
const appendExpressions: string[] = [];
const hiddenColumns: string[] = [];
const buildColumnLocator = (strategy: 'primary-key' | 'unique-key', locatorColumns: string[]): EditRowLocator => {
const valueColumns = locatorColumns.map((column, index) => {
const selectedColumn = findWritableResultColumnForSource(writableColumns, column);
if (selectedColumn) return selectedColumn;
const alias = buildQueryLocatorAlias(column, index + 1);
appendExpressions.push(buildQueryLocatorColumnExpression(dbType, column, alias));
hiddenColumns.push(alias);
return alias;
});
return {
strategy,
columns: locatorColumns,
valueColumns,
hiddenColumns: hiddenColumns.length > 0 ? [...hiddenColumns] : undefined,
writableColumns,
readOnly: false,
};
};
if (primaryKeys.length > 0) {
plan.pkColumns = primaryKeys;
plan.editLocator = buildColumnLocator('primary-key', primaryKeys);
} else {
const uniqueKeyGroups = resolveUniqueKeyGroupsFromIndexes(indexes);
const uniqueKeyGroup = uniqueKeyGroups.find((group) => group.length > 0);
if (uniqueKeyGroup) {
plan.editLocator = buildColumnLocator('unique-key', uniqueKeyGroup);
} else if (isOracleLikeDialect(dbType)) {
appendExpressions.push(buildQueryRowIDExpression(dbType));
plan.editLocator = {
strategy: 'oracle-rowid',
columns: ['ROWID'],
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
writableColumns,
readOnly: false,
};
} else {
const reason = !resIndexes?.success
? '无法加载唯一索引元数据,无法安全提交修改。'
: '未检测到主键或可用唯一索引,无法安全提交修改。';
plan.editLocator = buildQueryReadOnlyLocator(reason);
plan.warning = `查询结果保持只读:${tableRef.metadataDbName}.${tableRef.metadataTableName} ${reason}`;
}
}
plan.executedSql = appendQuerySelectExpressions(statement, appendExpressions);
return plan;
} catch {
const reason = `无法加载 ${tableRef.metadataDbName}.${tableRef.metadataTableName} 的主键/唯一索引元数据,无法安全提交修改。`;
plan.editLocator = buildQueryReadOnlyLocator(reason);
plan.warning = `查询结果保持只读:${reason}`;
return plan;
}
};
const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => {
const [query, setQuery] = useState(tab.query || 'SELECT * FROM ');
@@ -195,6 +496,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
columns: string[];
tableName?: string;
pkColumns: string[];
editLocator?: EditRowLocator;
readOnly: boolean;
truncated?: boolean;
pkLoading?: boolean;
@@ -249,6 +551,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const setQueryOptions = useStore(state => state.setQueryOptions);
const shortcutOptions = useStore(state => state.shortcutOptions);
const activeTabId = useStore(state => state.activeTabId);
const autoFetchVisible = useAutoFetchVisibility();
const currentSavedQuery = useMemo(() => {
const savedId = String(tab.savedQueryId || '').trim();
@@ -324,6 +627,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
// Fetch Database List
useEffect(() => {
if (!autoFetchVisible) {
return;
}
const fetchDbs = async () => {
const conn = connections.find(c => c.id === currentConnectionId);
if (!conn) return;
@@ -367,10 +674,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
};
void fetchDbs();
}, [currentConnectionId, connections]);
}, [autoFetchVisible, currentConnectionId, connections]);
// Fetch Metadata for Autocomplete (Cross-database)
useEffect(() => {
if (!autoFetchVisible) {
return;
}
const fetchMetadata = async () => {
const conn = connections.find(c => c.id === currentConnectionId);
if (!conn) return;
@@ -424,7 +735,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
};
void fetchMetadata();
}, [currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载
}, [autoFetchVisible, currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载
// Query ID management helpers
const setQueryId = (id: string) => {
@@ -511,6 +822,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
startColumn: word.startColumn,
endColumn: word.endColumn,
};
const activeConnection = sharedConnections.find(c => c.id === sharedCurrentConnectionId);
const activeDialect = resolveSqlDialect(
String(activeConnection?.config?.type || ''),
String(activeConnection?.config?.driver || ''),
{ oceanBaseProtocol: activeConnection?.config?.oceanBaseProtocol },
);
const dialectKeywords = resolveSqlKeywords(activeDialect);
const dialectFunctions = resolveSqlFunctions(activeDialect);
const stripQuotes = (ident: string) => {
let raw = (ident || '').trim();
@@ -766,7 +1085,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const expectsTableName = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM|TABLE|DESCRIBE|DESC|EXPLAIN)\s+[`"]?[\w.]*$/i.test(linePrefix.trim());
const shouldBoostKeywords = !expectsTableName
&& wordPrefix.length > 0
&& SQL_KEYWORDS.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix));
&& dialectKeywords.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix));
const sortGroups = shouldBoostKeywords
? { keyword: '00', func: '05', columnCurrent: '10', columnOther: '11', tableCurrent: '20', tableOther: '21', db: '30' }
: expectsTableName
@@ -868,7 +1187,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}));
// 关键字提示
const keywordSuggestions = SQL_KEYWORDS
const keywordSuggestions = dialectKeywords
.filter((k) => startsWithPrefix(k))
.map(k => ({
label: k,
@@ -879,7 +1198,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}));
// 内置函数提示
const funcSuggestions = SQL_FUNCTIONS
const funcSuggestions = dialectFunctions
.filter((f) => startsWithPrefix(f.name))
.map(f => ({
label: f.name,
@@ -1166,359 +1485,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return statements;
};
const getLeadingKeyword = (sql: string): string => {
const text = (sql || '').replace(/\r\n/g, '\n');
const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
const isWord = (ch: string) => /[A-Za-z0-9_]/.test(ch);
let inSingle = false;
let inDouble = false;
let inBacktick = false;
let escaped = false;
let inLineComment = false;
let inBlockComment = false;
let dollarTag: string | null = null;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const next = i + 1 < text.length ? text[i + 1] : '';
const prev = i > 0 ? text[i - 1] : '';
const next2 = i + 2 < text.length ? text[i + 2] : '';
if (!inSingle && !inDouble && !inBacktick) {
if (inLineComment) {
if (ch === '\n') inLineComment = false;
continue;
}
if (inBlockComment) {
if (ch === '*' && next === '/') {
i++;
inBlockComment = false;
}
continue;
}
if (ch === '/' && next === '*') {
i++;
inBlockComment = true;
continue;
}
if (ch === '#') {
inLineComment = true;
continue;
}
if (ch === '-' && next === '-' && (i === 0 || isWS(prev)) && (next2 === '' || isWS(next2))) {
i++;
inLineComment = true;
continue;
}
if (dollarTag) {
if (text.startsWith(dollarTag, i)) {
i += dollarTag.length - 1;
dollarTag = null;
}
continue;
}
if (ch === '$') {
const m = text.slice(i).match(/^\$[A-Za-z0-9_]*\$/);
if (m && m[0]) {
dollarTag = m[0];
i += dollarTag.length - 1;
continue;
}
}
}
if (escaped) {
escaped = false;
continue;
}
if ((inSingle || inDouble) && ch === '\\') {
escaped = true;
continue;
}
if (!inDouble && !inBacktick && ch === '\'') {
inSingle = !inSingle;
continue;
}
if (!inSingle && !inBacktick && ch === '"') {
inDouble = !inDouble;
continue;
}
if (!inSingle && !inDouble && ch === '`') {
inBacktick = !inBacktick;
continue;
}
if (inSingle || inDouble || inBacktick || dollarTag) continue;
if (isWS(ch)) continue;
if (isWord(ch)) {
let j = i;
while (j < text.length && isWord(text[j])) j++;
return text.slice(i, j).toLowerCase();
}
return '';
}
return '';
};
const splitSqlTail = (sql: string): { main: string; tail: string } => {
const text = (sql || '').replace(/\r\n/g, '\n');
const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
let inSingle = false;
let inDouble = false;
let inBacktick = false;
let escaped = false;
let inLineComment = false;
let inBlockComment = false;
let dollarTag: string | null = null;
let lastMeaningful = -1;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const next = i + 1 < text.length ? text[i + 1] : '';
const prev = i > 0 ? text[i - 1] : '';
const next2 = i + 2 < text.length ? text[i + 2] : '';
if (!inSingle && !inDouble && !inBacktick) {
if (dollarTag) {
if (text.startsWith(dollarTag, i)) {
lastMeaningful = i + dollarTag.length - 1;
i += dollarTag.length - 1;
dollarTag = null;
} else if (!isWS(ch)) {
lastMeaningful = i;
}
continue;
}
if (inLineComment) {
if (ch === '\n') inLineComment = false;
continue;
}
if (inBlockComment) {
if (ch === '*' && next === '/') {
i++;
inBlockComment = false;
}
continue;
}
// Start comments
if (ch === '/' && next === '*') {
i++;
inBlockComment = true;
continue;
}
if (ch === '#') {
inLineComment = true;
continue;
}
if (ch === '-' && next === '-' && (i === 0 || isWS(prev)) && (next2 === '' || isWS(next2))) {
i++;
inLineComment = true;
continue;
}
if (ch === '$') {
const m = text.slice(i).match(/^\$[A-Za-z0-9_]*\$/);
if (m && m[0]) {
dollarTag = m[0];
lastMeaningful = i + dollarTag.length - 1;
i += dollarTag.length - 1;
continue;
}
}
}
if (escaped) {
escaped = false;
} else if ((inSingle || inDouble) && ch === '\\') {
escaped = true;
} else {
if (!inDouble && !inBacktick && ch === '\'') inSingle = !inSingle;
else if (!inSingle && !inBacktick && ch === '"') inDouble = !inDouble;
else if (!inSingle && !inDouble && ch === '`') inBacktick = !inBacktick;
}
if (!inLineComment && !inBlockComment && !isWS(ch)) {
lastMeaningful = i;
}
}
if (lastMeaningful < 0) return { main: '', tail: text };
return { main: text.slice(0, lastMeaningful + 1), tail: text.slice(lastMeaningful + 1) };
};
const findTopLevelKeyword = (sql: string, keyword: string): number => {
const text = sql;
const kw = keyword.toLowerCase();
const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
const isWord = (ch: string) => /[A-Za-z0-9_]/.test(ch);
let inSingle = false;
let inDouble = false;
let inBacktick = false;
let escaped = false;
let inLineComment = false;
let inBlockComment = false;
let dollarTag: string | null = null;
let parenDepth = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const next = i + 1 < text.length ? text[i + 1] : '';
const prev = i > 0 ? text[i - 1] : '';
const next2 = i + 2 < text.length ? text[i + 2] : '';
if (!inSingle && !inDouble && !inBacktick) {
if (inLineComment) {
if (ch === '\n') inLineComment = false;
continue;
}
if (inBlockComment) {
if (ch === '*' && next === '/') {
i++;
inBlockComment = false;
}
continue;
}
if (ch === '/' && next === '*') {
i++;
inBlockComment = true;
continue;
}
if (ch === '#') {
inLineComment = true;
continue;
}
if (ch === '-' && next === '-' && (i === 0 || isWS(prev)) && (next2 === '' || isWS(next2))) {
i++;
inLineComment = true;
continue;
}
if (dollarTag) {
if (text.startsWith(dollarTag, i)) {
i += dollarTag.length - 1;
dollarTag = null;
}
continue;
}
if (ch === '$') {
const m = text.slice(i).match(/^\$[A-Za-z0-9_]*\$/);
if (m && m[0]) {
dollarTag = m[0];
i += dollarTag.length - 1;
continue;
}
}
}
if (escaped) {
escaped = false;
continue;
}
if ((inSingle || inDouble) && ch === '\\') {
escaped = true;
continue;
}
if (!inDouble && !inBacktick && ch === '\'') {
inSingle = !inSingle;
continue;
}
if (!inSingle && !inBacktick && ch === '"') {
inDouble = !inDouble;
continue;
}
if (!inSingle && !inDouble && ch === '`') {
inBacktick = !inBacktick;
continue;
}
if (inSingle || inDouble || inBacktick || dollarTag) continue;
if (ch === '(') { parenDepth++; continue; }
if (ch === ')') { if (parenDepth > 0) parenDepth--; continue; }
if (parenDepth !== 0) continue;
if (!isWord(ch)) continue;
if (text.slice(i, i + kw.length).toLowerCase() !== kw) continue;
const before = i - 1 >= 0 ? text[i - 1] : '';
const after = i + kw.length < text.length ? text[i + kw.length] : '';
if ((before && isWord(before)) || (after && isWord(after))) continue;
return i;
}
return -1;
};
const applyAutoLimit = (sql: string, dbType: string, maxRows: number): { sql: string; applied: boolean; maxRows: number } => {
if (!Number.isFinite(maxRows) || maxRows <= 0) return { sql, applied: false, maxRows };
const normalizedType = (dbType || 'mysql').toLowerCase();
// 只对 SELECT 语句自动加限制
const keyword = getLeadingKeyword(sql);
if (keyword !== 'SELECT') return { sql, applied: false, maxRows };
const { main, tail } = splitSqlTail(sql);
if (!main.trim()) return { sql, applied: false, maxRows };
const fromPos = findTopLevelKeyword(main, 'from');
const limitPos = findTopLevelKeyword(main, 'limit');
// 已有 LIMIT → 不注入
if (limitPos >= 0 && (fromPos < 0 || limitPos > fromPos)) return { sql, applied: false, maxRows };
const fetchPos = findTopLevelKeyword(main, 'fetch');
// 已有 FETCH → 不注入
if (fetchPos >= 0 && (fromPos < 0 || fetchPos > fromPos)) return { sql, applied: false, maxRows };
// SQL Server / mssql: 检查是否已有 TOP未有则注入 SELECT TOP N
if (normalizedType === 'sqlserver' || normalizedType === 'mssql') {
const topPos = findTopLevelKeyword(main, 'top');
if (topPos >= 0) return { sql, applied: false, maxRows }; // 已有 TOP
// 在 SELECT 关键字之后插入 TOP N
const selectPos = findTopLevelKeyword(main, 'select');
if (selectPos < 0) return { sql, applied: false, maxRows };
const afterSelect = selectPos + 'SELECT'.length;
// 处理 SELECT DISTINCT 的情况
const restAfterSelect = main.slice(afterSelect);
const distinctMatch = restAfterSelect.match(/^(\s+DISTINCT\b)/i);
const insertOffset = distinctMatch ? afterSelect + distinctMatch[1].length : afterSelect;
const nextMain = main.slice(0, insertOffset) + ` TOP ${maxRows}` + main.slice(insertOffset);
return { sql: nextMain + tail, applied: true, maxRows };
}
// Oracle / Dameng: 使用 FETCH FIRST N ROWS ONLYOracle 12c+ 标准语法)
if (normalizedType === 'oracle' || normalizedType === 'dameng') {
// 检查是否已有 ROWNUM 限制
const rownumPos = findTopLevelKeyword(main, 'rownum');
if (rownumPos >= 0) return { sql, applied: false, maxRows };
const offsetPos = findTopLevelKeyword(main, 'offset');
if (offsetPos >= 0 && (fromPos < 0 || offsetPos > fromPos)) return { sql, applied: false, maxRows };
const nextMain = main.trimEnd() + ` FETCH FIRST ${maxRows} ROWS ONLY`;
return { sql: nextMain + tail, applied: true, maxRows };
}
// 通用 LIMIT 语法MySQL, PostgreSQL, SQLite, ClickHouse, DuckDB 等)
const offsetPos = findTopLevelKeyword(main, 'offset');
const forPos = findTopLevelKeyword(main, 'for');
const lockPos = findTopLevelKeyword(main, 'lock');
const candidates = [offsetPos, forPos, lockPos]
.filter(pos => pos >= 0 && (fromPos < 0 || pos > fromPos));
const insertAt = candidates.length > 0 ? Math.min(...candidates) : main.length;
const before = main.slice(0, insertAt).trimEnd();
const after = main.slice(insertAt).trimStart();
const nextMain = [before, `LIMIT ${maxRows}`, after].filter(Boolean).join(' ').trim();
return { sql: nextMain + tail, applied: true, maxRows };
};
const getSelectedSQL = (): string => {
const editor = editorRef.current;
if (!editor) return '';
@@ -1644,8 +1610,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
try {
const rawSQL = getSelectedSQL() || currentQuery;
const dbType = String((buildRpcConnectionConfig(config) as any).type || 'mysql');
const normalizedDbType = dbType.trim().toLowerCase();
const rpcConfig = buildRpcConnectionConfig(config) as any;
const dbType = String(rpcConfig.type || 'mysql');
const driver = String((config as any).driver || '');
const normalizedDbType = String(resolveSqlDialect(dbType, driver, {
oceanBaseProtocol: (config as any).oceanBaseProtocol,
})).trim().toLowerCase();
const normalizedRawSQL = String(rawSQL || '').replace(//g, ';');
// MongoDB 仍走逐条执行的旧路径
@@ -1685,6 +1655,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
executedSql = shellConvert.command;
}
}
if (wantsLimitProbe) {
const limitResult = applyMongoQueryAutoLimit(executedSql, maxRows);
if (limitResult.applied) {
executedSql = limitResult.command;
}
}
const startTime = Date.now();
let queryId: string;
try {
@@ -1765,26 +1741,36 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
} else {
// 非 MongoDB使用 DBQueryMulti 一次性执行多条 SQL后端返回多结果集
let fullSQL = normalizedRawSQL;
if (!fullSQL.trim()) {
const sourceStatements = splitSQLStatements(normalizedRawSQL);
if (sourceStatements.length === 0) {
message.info('没有可执行的 SQL。');
setResultSets([]);
setActiveResultKey('');
return;
}
const forceReadOnlyResult = connCaps.forceReadOnlyQueryResult;
const statementPlans: QueryStatementPlan[] = [];
for (const statement of sourceStatements) {
statementPlans.push(await resolveQueryLocatorPlan({
statement,
dbType: normalizedDbType,
currentDb,
config,
forceReadOnly: forceReadOnlyResult,
}));
}
// 自动给 SELECT 语句注入行数限制(防止大结果集卡死)
const maxRowsForLimit = Number(queryOptions?.maxRows) || 0;
let anyLimitApplied = false;
if (Number.isFinite(maxRowsForLimit) && maxRowsForLimit > 0) {
const stmts = splitSQLStatements(fullSQL);
const limitedStmts = stmts.map(s => {
const result = applyAutoLimit(s, normalizedDbType, maxRowsForLimit);
if (result.applied) anyLimitApplied = true;
return result.sql;
});
fullSQL = limitedStmts.join(';\n');
}
const executablePlans = statementPlans.map((plan) => {
if (!Number.isFinite(maxRowsForLimit) || maxRowsForLimit <= 0) return plan;
const result = applyQueryAutoLimit(plan.executedSql, normalizedDbType, maxRowsForLimit, driver);
if (result.applied) anyLimitApplied = true;
return { ...plan, executedSql: result.sql };
});
const fullSQL = executablePlans.map((plan) => plan.executedSql).join(';\n');
const startTime = Date.now();
let queryId: string;
@@ -1841,16 +1827,13 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const resultSetDataArray = Array.isArray(res.data) ? (res.data as any[]) : [];
const nextResultSets: ResultSet[] = [];
const maxRows = Number(queryOptions?.maxRows) || 0;
const forceReadOnlyResult = connCaps.forceReadOnlyQueryResult;
let anyTruncated = false;
const pendingPk: Array<{ resultKey: string; tableName: string }> = [];
// 前端也拆分语句用于匹配原始 SQL展示和表名检测
const statements = splitSQLStatements(fullSQL);
for (let idx = 0; idx < resultSetDataArray.length; idx++) {
const rsData = resultSetDataArray[idx];
const rawStatement = (idx < statements.length) ? statements[idx] : '';
const plan = executablePlans[idx];
const originalSql = plan?.originalSql || '';
const executedSql = plan?.executedSql || originalSql;
// 检查是否为 affectedRows 类结果集
const isAffectedResult = Array.isArray(rsData.rows) && rsData.rows.length === 1
@@ -1863,8 +1846,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
(row as any)[GONAVI_ROW_KEY] = 0;
nextResultSets.push({
key: `result-${idx + 1}`,
sql: rawStatement,
exportSql: rawStatement,
sql: executedSql,
exportSql: originalSql,
rows: [row],
columns: ['affectedRows'],
pkColumns: [],
@@ -1887,32 +1870,18 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i;
});
let simpleTableName: string | undefined = undefined;
if (rawStatement) {
// 支持多行 SQLSELECT [cols] FROM [schema.]table [WHERE...] [ORDER BY...] [LIMIT...] 等
// JOIN 查询表名歧义,不提取
const hasJoin = /\bJOIN\b/i.test(rawStatement);
const tableMatch = !hasJoin
? rawStatement.match(/^\s*SELECT\s+.+?\s+FROM\s+(?:[\w`"\[\].]+\.)?[`"\[]?(\w+)[`"\]]?\s*(?:$|[\s;])/im)
: null;
if (tableMatch) {
simpleTableName = tableMatch[1];
if (!forceReadOnlyResult) {
pendingPk.push({ resultKey: `result-${idx + 1}`, tableName: simpleTableName });
}
}
}
const tableRef = plan?.tableRef;
const editLocator = plan?.editLocator;
nextResultSets.push({
key: `result-${idx + 1}`,
sql: rawStatement,
exportSql: rawStatement,
sql: executedSql,
exportSql: originalSql,
rows,
columns: cols,
tableName: simpleTableName,
pkColumns: [],
readOnly: true,
pkLoading: !!simpleTableName,
tableName: tableRef?.tableName,
pkColumns: plan?.pkColumns || [],
editLocator,
readOnly: forceReadOnlyResult || !editLocator || editLocator.readOnly,
truncated
});
}
@@ -1921,21 +1890,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
setResultSets(nextResultSets);
setActiveResultKey(nextResultSets[0]?.key || '');
pendingPk.forEach(({ resultKey, tableName }) => {
DBGetColumns(buildRpcConnectionConfig(config) as any, currentDb, tableName)
.then((resCols: any) => {
if (runSeqRef.current !== runSeq) return;
if (!resCols?.success) {
setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, pkLoading: false, readOnly: false } : rs));
return;
}
const primaryKeys = (resCols.data as ColumnDefinition[]).filter(c => c.key === 'PRI').map(c => c.name);
setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, pkColumns: primaryKeys, pkLoading: false, readOnly: false } : rs));
})
.catch(() => {
if (runSeqRef.current !== runSeq) return;
setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, pkLoading: false, readOnly: false } : rs));
});
executablePlans.forEach((plan) => {
if (plan.warning) message.warning(plan.warning);
});
// 后端附带的提示信息(如数据源不支持原生多语句执行的回退提示)
@@ -2186,7 +2142,31 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return saved;
};
const handleQuickSave = () => {
const handleQuickSave = async () => {
const filePath = String(tab.filePath || '').trim();
if (filePath) {
const sql = getCurrentQuery();
try {
const res = await WriteSQLFile(filePath, sql);
if (!res.success) {
message.error('保存 SQL 文件失败: ' + (res.message || '未知错误'));
return;
}
addTab({
...tab,
query: sql,
connectionId: currentConnectionId,
dbName: currentDb || tab.dbName || '',
filePath,
savedQueryId: undefined,
});
message.success('SQL 文件已保存!');
} catch (error) {
message.error('保存 SQL 文件失败: ' + (error instanceof Error ? error.message : String(error)));
}
return;
}
const existed = currentSavedQuery || null;
const fallbackSavedId = String(tab.savedQueryId || '').trim();
const saveId = existed?.id || fallbackSavedId || '';
@@ -2444,6 +2424,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
dbName={currentDb}
connectionId={currentConnectionId}
pkColumns={rs.pkColumns}
editLocator={rs.editLocator}
onReload={() => handleReloadResult(rs.key, rs.sql)}
readOnly={rs.readOnly}
/>

View File

@@ -1,12 +1,20 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Table, Input, Button, Space, Tag, Tree, Spin, message, Modal, Form, InputNumber, Popconfirm, Tooltip, Radio } from 'antd';
import type { RadioChangeEvent } from 'antd';
import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, SearchOutlined, ClockCircleOutlined, CopyOutlined, FolderOpenOutlined, KeyOutlined, RightOutlined, DownOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { RedisKeyInfo, RedisValue, StreamEntry } from '../types';
import Editor from '@monaco-editor/react';
import type { DataNode } from 'antd/es/tree';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import {
blurToFilter,
isMacLikePlatform,
normalizeBlurForPlatform,
normalizeOpacityForPlatform,
resolveAppearanceValues,
resolveTextInputSafeBackdropFilter,
} from '../utils/appearance';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import {
applyRenamedRedisKeyState,
@@ -19,6 +27,9 @@ import {
type RedisTreeDataNode,
} from './redisViewerTree';
import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { normalizeRedisSearchDraftChange, normalizeRedisSearchInput, type RedisSearchMode } from '../utils/redisSearchPattern';
import { decodeRedisUtf8Value, formatRedisStringValue, toHexDisplay } from '../utils/redisValueDisplay';
const { Search } = Input;
@@ -39,148 +50,6 @@ interface RedisViewerProps {
redisDB: number;
}
// 尝试多种方式解码二进制数据
const tryDecodeValue = (value: string): { displayValue: string; encoding: string; needsHex: boolean } => {
if (!value || value.length === 0) {
return { displayValue: '', encoding: 'UTF-8', needsHex: false };
}
// 统计字节分布
let nullCount = 0;
let printableCount = 0;
let highByteCount = 0;
const sampleSize = Math.min(value.length, 200);
for (let i = 0; i < sampleSize; i++) {
const code = value.charCodeAt(i);
if (code === 0) {
nullCount++;
} else if (code >= 32 && code < 127) {
printableCount++;
} else if (code >= 128) {
highByteCount++;
}
}
// 如果超过30%是null字节很可能是二进制数据显示十六进制
if (nullCount / sampleSize > 0.3) {
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
}
// 如果超过70%是可打印ASCII字符直接显示
if (printableCount / sampleSize > 0.7) {
return { displayValue: value, encoding: 'UTF-8', needsHex: false };
}
// 尝试UTF-8解码
if (highByteCount > 0) {
try {
const bytes = new Uint8Array(value.length);
for (let i = 0; i < value.length; i++) {
bytes[i] = value.charCodeAt(i) & 0xFF;
}
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
// 检查解码质量
let validChars = 0;
let replacementChars = 0;
let controlChars = 0;
for (let i = 0; i < Math.min(decoded.length, 200); i++) {
const code = decoded.charCodeAt(i);
if (code === 0xFFFD) {
replacementChars++;
} else if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
controlChars++;
} else if ((code >= 32 && code < 127) || (code >= 0x4E00 && code <= 0x9FFF) || (code >= 0x3000 && code <= 0x303F)) {
// ASCII可打印字符、中文字符、中文标点
validChars++;
}
}
const totalChecked = Math.min(decoded.length, 200);
// 如果替换字符超过10%或控制字符超过20%说明不是有效的UTF-8文本
if (replacementChars / totalChecked > 0.1 || controlChars / totalChecked > 0.2) {
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
}
// 如果有效字符超过50%使用UTF-8解码
if (validChars / totalChecked > 0.5) {
return { displayValue: decoded, encoding: 'UTF-8', needsHex: false };
}
} catch (e) {
// UTF-8解码失败
}
}
// 默认显示十六进制
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
};
// 检测是否为二进制数据(包含大量不可打印字符)
const isBinaryData = (value: string): boolean => {
if (!value || value.length === 0) return false;
// 检查前 100 个字符中不可打印字符的比例
const sampleSize = Math.min(value.length, 100);
let nonPrintableCount = 0;
for (let i = 0; i < sampleSize; i++) {
const code = value.charCodeAt(i);
// 不可打印字符控制字符0-31除了 9, 10, 13和 DEL127
if ((code < 32 && code !== 9 && code !== 10 && code !== 13) || code === 127 || code > 255) {
nonPrintableCount++;
}
}
// 如果超过 10% 是不可打印字符,认为是二进制数据
return nonPrintableCount / sampleSize > 0.1;
};
// 将字符串转换为十六进制显示
const toHexDisplay = (value: string): string => {
const bytes: string[] = [];
const ascii: string[] = [];
let result = '';
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i);
bytes.push(code.toString(16).padStart(2, '0').toUpperCase());
// 可打印 ASCII 字符显示原字符,否则显示点
ascii.push(code >= 32 && code < 127 ? value[i] : '.');
if (bytes.length === 16 || i === value.length - 1) {
const offset = (Math.floor(i / 16) * 16).toString(16).padStart(8, '0').toUpperCase();
const hexPart = bytes.join(' ').padEnd(47, ' ');
const asciiPart = ascii.join('');
result += `${offset} ${hexPart} |${asciiPart}|\n`;
bytes.length = 0;
ascii.length = 0;
}
}
return result;
};
// 尝试解析并格式化 JSON
const tryFormatJson = (value: string): { isJson: boolean; formatted: string } => {
try {
const parsed = JSON.parse(value);
return { isJson: true, formatted: JSON.stringify(parsed, null, 2) };
} catch {
return { isJson: false, formatted: value };
}
};
// 格式化字符串值 - 支持 JSON、二进制数据检测和智能解码
const formatStringValue = (value: string): { displayValue: string; isBinary: boolean; isJson: boolean; encoding?: string } => {
// 先检测是否为二进制数据
if (isBinaryData(value)) {
const { displayValue, encoding, needsHex } = tryDecodeValue(value);
return { displayValue, isBinary: needsHex, isJson: false, encoding };
}
// 尝试 JSON 格式化
const { isJson, formatted } = tryFormatJson(value);
return { displayValue: formatted, isBinary: false, isJson, encoding: 'UTF-8' };
};
// 可拖拽分隔条组件 - 使用直接 DOM 操作避免卡顿
const ResizableDivider: React.FC<{
onResizeEnd: (newWidth: number) => void;
@@ -283,8 +152,16 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const resolvedAppearance = resolveAppearanceValues(appearance);
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const blur = normalizeBlurForPlatform(resolvedAppearance.blur);
const disableLocalBackdropFilter = isMacLikePlatform();
const connection = connections.find(c => c.id === connectionId);
const workbenchTheme = useMemo(() => buildRedisWorkbenchTheme({ darkMode, opacity, blur }), [blur, darkMode, opacity]);
const workbenchTheme = useMemo(
() => buildRedisWorkbenchTheme({ darkMode, opacity, blur, disableBackdropFilter: disableLocalBackdropFilter }),
[blur, darkMode, disableLocalBackdropFilter, opacity],
);
const workbenchBackdropFilter = useMemo(
() => resolveTextInputSafeBackdropFilter(blurToFilter(blur), disableLocalBackdropFilter),
[blur, disableLocalBackdropFilter],
);
const keyAccentColor = workbenchTheme.accent;
const jsonAccentColor = darkMode ? '#f6c453' : '#1890ff';
const valueToolbarBg = workbenchTheme.panelBgStrong;
@@ -293,7 +170,9 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const [keys, setKeys] = useState<RedisKeyInfo[]>([]);
const [loading, setLoading] = useState(false);
const [searchInput, setSearchInput] = useState('');
const [searchPattern, setSearchPattern] = useState('*');
const [searchMode, setSearchMode] = useState<RedisSearchMode>('fuzzy');
const [cursor, setCursor] = useState<string>('0');
const [hasMore, setHasMore] = useState(false);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
@@ -467,15 +346,37 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
useEffect(() => {
loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false));
}, [redisDB]);
}, [loadKeys, redisDB]);
const executeSearch = useCallback((value: string, mode: RedisSearchMode = searchMode) => {
const normalized = normalizeRedisSearchInput(value, mode);
setSearchInput(normalized.keyword);
setSearchPattern(normalized.pattern);
setCursor('0');
loadKeys(normalized.pattern, '0', false, getRedisScanLoadCount(normalized.pattern, false));
}, [loadKeys, searchMode]);
const handleSearch = (value: string) => {
const pattern = value.trim() || '*';
setSearchPattern(pattern);
setCursor('0');
loadKeys(pattern, '0', false, getRedisScanLoadCount(pattern, false));
executeSearch(value);
};
const handleSearchInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const normalized = normalizeRedisSearchDraftChange(event.target.value, searchMode);
setSearchInput(normalized.keyword);
if (!normalized.shouldSearchImmediately) {
return;
}
setSearchPattern(normalized.pattern);
setCursor('0');
loadKeys(normalized.pattern, '0', false, getRedisScanLoadCount(normalized.pattern, false));
};
const handleSearchModeChange = useCallback((event: RadioChangeEvent) => {
const nextMode = event.target.value as RedisSearchMode;
setSearchMode(nextMode);
executeSearch(searchInput, nextMode);
}, [executeSearch, searchInput]);
const handleLoadMore = () => {
if (!hasMore || loading) {
return;
@@ -1040,6 +941,22 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
const renderValueEditor = () => {
const processValueForCurrentView = (value: string) => {
if (viewMode === 'hex') {
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
}
if (viewMode === 'text') {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
}
if (viewMode === 'utf8') {
return { displayValue: decodeRedisUtf8Value(value), isBinary: false, isJson: false, encoding: 'UTF-8' };
}
return formatRedisStringValue(value);
};
if (!keyValue || !selectedKey) {
return (
<div
@@ -1061,33 +978,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const renderStringValue = () => {
const strValue = String(keyValue.value);
// 根据查看模式生成显示内容
const getDisplayContent = () => {
if (viewMode === 'hex') {
return { displayValue: toHexDisplay(strValue), isBinary: true, encoding: 'HEX' };
} else if (viewMode === 'text') {
return { displayValue: strValue, isBinary: false, encoding: 'Text' };
} else if (viewMode === 'utf8') {
try {
const bytes = new Uint8Array(strValue.length);
for (let i = 0; i < strValue.length; i++) {
bytes[i] = strValue.charCodeAt(i) & 0xFF;
}
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
return { displayValue: decoded, isBinary: false, encoding: 'UTF-8' };
} catch (e) {
return { displayValue: strValue, isBinary: false, encoding: 'UTF-8 (失败)' };
}
} else {
// auto mode
const { displayValue, isBinary, isJson, encoding } = formatStringValue(strValue);
return { displayValue, isBinary, encoding };
}
};
const { displayValue, isBinary, encoding } = getDisplayContent();
const isJson = viewMode === 'auto' && formatStringValue(strValue).isJson;
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(strValue);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
@@ -1146,31 +1037,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
const renderHashValue = () => {
// 根据查看模式处理值
const processValue = (value: string) => {
if (viewMode === 'hex') {
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
} else if (viewMode === 'text') {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
} else if (viewMode === 'utf8') {
try {
const bytes = new Uint8Array(value.length);
for (let i = 0; i < value.length; i++) {
bytes[i] = value.charCodeAt(i) & 0xFF;
}
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
} catch (e) {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
}
} else {
// auto mode
return formatStringValue(value);
}
};
const data = Object.entries(keyValue.value as Record<string, string>).map(([field, value]) => {
const { displayValue, isBinary, isJson, encoding } = processValue(value);
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(value);
return { field, value, displayValue, isBinary, isJson, encoding };
});
@@ -1194,7 +1062,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisDeleteHashField(buildRpcConnectionConfig(config), selectedKey, field);
const res = await (window as any).go.app.App.RedisDeleteHashField(buildRpcConnectionConfig(config), selectedKey, [field]);
if (res.success) {
message.success('删除成功');
loadKeyValue(selectedKey);
@@ -1214,9 +1082,9 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
title: '添加字段',
content: (
<Form id="add-hash-field-form" layout="vertical">
<Form.Item label="字段名" name="field" rules={[{ required: true }]}>
<Input id="new-hash-field" />
</Form.Item>
<Form.Item label="字段名" name="field" rules={[{ required: true }]}>
<Input id="new-hash-field" {...noAutoCapInputProps} />
</Form.Item>
<Form.Item label="值" name="value" rules={[{ required: true }]}>
<Input.TextArea id="new-hash-value" rows={4} />
</Form.Item>
@@ -1307,31 +1175,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
const renderListValue = () => {
// 根据查看模式处理值
const processValue = (value: string) => {
if (viewMode === 'hex') {
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
} else if (viewMode === 'text') {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
} else if (viewMode === 'utf8') {
try {
const bytes = new Uint8Array(value.length);
for (let i = 0; i < value.length; i++) {
bytes[i] = value.charCodeAt(i) & 0xFF;
}
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
} catch (e) {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
}
} else {
// auto mode
return formatStringValue(value);
}
};
const data = (keyValue.value as string[]).map((value, index) => {
const { displayValue, isBinary, isJson, encoding } = processValue(value);
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(value);
return { index, value, displayValue, isBinary, isJson, encoding };
});
@@ -1477,31 +1322,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
const renderSetValue = () => {
// 根据查看模式处理值
const processValue = (value: string) => {
if (viewMode === 'hex') {
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
} else if (viewMode === 'text') {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
} else if (viewMode === 'utf8') {
try {
const bytes = new Uint8Array(value.length);
for (let i = 0; i < value.length; i++) {
bytes[i] = value.charCodeAt(i) & 0xFF;
}
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
} catch (e) {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
}
} else {
// auto mode
return formatStringValue(value);
}
};
const data = (keyValue.value as string[]).map((member, index) => {
const { displayValue, isBinary, isJson, encoding } = processValue(member);
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(member);
return { index, member, displayValue, isBinary, isJson, encoding };
});
@@ -1614,31 +1436,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
const renderZSetValue = () => {
// 根据查看模式处理值
const processValue = (value: string) => {
if (viewMode === 'hex') {
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
} else if (viewMode === 'text') {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
} else if (viewMode === 'utf8') {
try {
const bytes = new Uint8Array(value.length);
for (let i = 0; i < value.length; i++) {
bytes[i] = value.charCodeAt(i) & 0xFF;
}
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
} catch (e) {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
}
} else {
// auto mode
return formatStringValue(value);
}
};
const data = (keyValue.value as Array<{ member: string; score: number }>).map((item, index) => {
const { displayValue, isBinary, isJson, encoding } = processValue(item.member);
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(item.member);
return { ...item, index, displayMember: displayValue, isBinary, isJson, encoding };
});
@@ -1779,30 +1578,9 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
const renderStreamValue = () => {
const processValue = (value: string) => {
if (viewMode === 'hex') {
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
} else if (viewMode === 'text') {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
} else if (viewMode === 'utf8') {
try {
const bytes = new Uint8Array(value.length);
for (let i = 0; i < value.length; i++) {
bytes[i] = value.charCodeAt(i) & 0xFF;
}
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
} catch (e) {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
}
} else {
return formatStringValue(value);
}
};
const data = (keyValue.value as StreamEntry[]).map((item, index) => {
const rawFieldsText = JSON.stringify(item.fields ?? {}, null, 2);
const { displayValue, isBinary, isJson, encoding } = processValue(rawFieldsText);
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(rawFieldsText);
return {
index,
id: item.id,
@@ -1888,7 +1666,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
<div>
<div style={{ marginBottom: 8 }}>
<label>ID *</label>
<Input id="new-stream-id" placeholder="例如: * 或 1723110000000-0" />
<Input id="new-stream-id" {...noAutoCapInputProps} placeholder="例如: * 或 1723110000000-0" />
</div>
<div>
<label> JSON</label>
@@ -2050,7 +1828,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
}
return (
<div className="redis-viewer-workbench" style={{ display: 'flex', height: '100%', gap: 12, padding: 12, background: workbenchTheme.appBg, backdropFilter: blurToFilter(blur), WebkitBackdropFilter: blurToFilter(blur) }}>
<div className="redis-viewer-workbench" style={{ display: 'flex', height: '100%', gap: 12, padding: 12, background: workbenchTheme.appBg, backdropFilter: workbenchBackdropFilter, WebkitBackdropFilter: workbenchBackdropFilter }}>
{/* Left: Key List */}
<div ref={leftPanelRef} style={{ width: leftPanelWidth, minWidth: 300, display: 'flex', flexDirection: 'column', flexShrink: 0, gap: 12 }}>
<div style={{ ...workbenchCardStyle, padding: 12 }}>
@@ -2062,10 +1840,23 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
<Tag style={mutedPillTagStyle}>{keys.length} Keys</Tag>
</div>
<Space.Compact style={{ width: '100%' }}>
<Radio.Group
value={searchMode}
onChange={handleSearchModeChange}
buttonStyle="solid"
style={{ flexShrink: 0 }}
>
<Radio.Button value="fuzzy"></Radio.Button>
<Radio.Button value="exact"></Radio.Button>
</Radio.Group>
<Search
placeholder="搜索 Key (支持 * 通配符)"
defaultValue="*"
{...noAutoCapInputProps}
style={{ flex: 1 }}
placeholder={searchMode === 'exact' ? '输入完整 Key / 命名空间精确搜索' : '搜索 Key模糊匹配'}
value={searchInput}
onChange={handleSearchInputChange}
onSearch={handleSearch}
allowClear
enterButton={<SearchOutlined />}
/>
</Space.Compact>
@@ -2152,7 +1943,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
>
<Editor
height="450px"
language={tryFormatJson(editValue).isJson ? 'json' : 'plaintext'}
language={formatRedisStringValue(editValue).isJson ? 'json' : 'plaintext'}
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={editValue}
onChange={(value) => setEditValue(value || '')}
@@ -2177,7 +1968,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
>
<Form form={newKeyForm} layout="vertical" initialValues={{ ttl: -1 }}>
<Form.Item name="key" label="Key" rules={[{ required: true, message: '请输入 Key' }]}>
<Input placeholder="key name" />
<Input {...noAutoCapInputProps} placeholder="key name" />
</Form.Item>
<Form.Item name="value" label="值" rules={[{ required: true, message: '请输入值' }]}>
<Input.TextArea rows={4} placeholder="value" />
@@ -2207,7 +1998,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
rules={[{ required: true, message: '请输入新的 Key 名称' }]}
extra={renameTargetKey ? `原始 Key${renameTargetKey}` : undefined}
>
<Input placeholder="new:key:name" />
<Input {...noAutoCapInputProps} placeholder="new:key:name" />
</Form.Item>
</Form>
</Modal>

View File

@@ -0,0 +1,154 @@
import { Button } from 'antd';
import { CloseOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
import type { SecurityUpdateStatus } from '../types';
import { getSecurityUpdateStatusMeta } from '../utils/securityUpdatePresentation';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import {
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
SECURITY_UPDATE_BANNER_CLASS,
getSecurityUpdateActionButtonStyle,
getSecurityUpdateBannerSurfaceStyle,
} from '../utils/securityUpdateVisuals';
interface SecurityUpdateBannerProps {
status: SecurityUpdateStatus;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
surfaceOpacity?: number;
onStart: () => void;
onRetry: () => void;
onRestart: () => void;
onOpenDetails: () => void;
onDismiss: () => void;
}
const resolvePrimaryAction = (
status: SecurityUpdateStatus,
actions: Pick<SecurityUpdateBannerProps, 'onStart' | 'onRetry' | 'onRestart' | 'onOpenDetails'>,
) => {
switch (status.overallStatus) {
case 'postponed':
return {
label: '立即更新',
onClick: actions.onStart,
};
case 'needs_attention':
return {
label: '查看详情',
onClick: actions.onOpenDetails,
};
case 'rolled_back':
return {
label: '重新开始更新',
onClick: actions.onRestart,
};
default:
return {
label: '查看详情',
onClick: actions.onOpenDetails,
};
}
};
const resolveSecondaryAction = (
status: SecurityUpdateStatus,
actions: Pick<SecurityUpdateBannerProps, 'onRetry' | 'onOpenDetails'>,
) => {
switch (status.overallStatus) {
case 'needs_attention':
return {
label: '重新检查',
onClick: actions.onRetry,
};
case 'rolled_back':
return {
label: '查看详情',
onClick: actions.onOpenDetails,
};
default:
return null;
}
};
const SecurityUpdateBanner = ({
status,
darkMode,
overlayTheme,
surfaceOpacity = 1,
onStart,
onRetry,
onRestart,
onOpenDetails,
onDismiss,
}: SecurityUpdateBannerProps) => {
const statusMeta = getSecurityUpdateStatusMeta(status);
const primaryAction = resolvePrimaryAction(status, { onStart, onRetry, onRestart, onOpenDetails });
const secondaryAction = resolveSecondaryAction(status, { onRetry, onOpenDetails });
const actionButtonStyle = getSecurityUpdateActionButtonStyle();
return (
<div
className={SECURITY_UPDATE_BANNER_CLASS}
style={{
margin: '12px 12px 0',
padding: '14px 16px',
borderRadius: 16,
...getSecurityUpdateBannerSurfaceStyle(overlayTheme, surfaceOpacity),
display: 'flex',
alignItems: 'center',
gap: 16,
overflow: 'hidden',
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 14,
display: 'grid',
placeItems: 'center',
background: overlayTheme.iconBg,
color: overlayTheme.iconColor,
flexShrink: 0,
fontSize: 18,
}}
>
<SafetyCertificateOutlined />
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: overlayTheme.titleText }}>
</div>
<div style={{ marginTop: 4, fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{statusMeta.description}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
{secondaryAction ? (
<Button className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={secondaryAction.onClick}>
{secondaryAction.label}
</Button>
) : null}
<Button
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
style={actionButtonStyle}
type="primary"
onClick={primaryAction.onClick}
>
{primaryAction.label}
</Button>
<Button
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
style={{ ...actionButtonStyle, width: 36, minWidth: 36, paddingInline: 0 }}
type="text"
icon={<CloseOutlined />}
onClick={onDismiss}
/>
</div>
</div>
);
};
export type { SecurityUpdateBannerProps };
export default SecurityUpdateBanner;

View File

@@ -0,0 +1,133 @@
import { Button, Modal } from 'antd';
import { SafetyCertificateOutlined } from '@ant-design/icons';
import type { CSSProperties } from 'react';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import {
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
SECURITY_UPDATE_MODAL_CLASS,
getSecurityUpdateActionButtonStyle,
getSecurityUpdateShellSurfaceStyle,
} from '../utils/securityUpdateVisuals';
interface SecurityUpdateIntroModalProps {
open: boolean;
loading?: boolean;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
surfaceOpacity?: number;
onStart: () => void;
onPostpone: () => void;
onViewDetails: () => void;
}
const actionButtonStyle: CSSProperties = {
...getSecurityUpdateActionButtonStyle(),
height: 38,
paddingInline: 18,
};
const SecurityUpdateIntroModal = ({
open,
loading = false,
darkMode,
overlayTheme,
surfaceOpacity = 1,
onStart,
onPostpone,
onViewDetails,
}: SecurityUpdateIntroModalProps) => {
return (
<Modal
rootClassName={SECURITY_UPDATE_MODAL_CLASS}
title={(
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div
style={{
width: 38,
height: 38,
borderRadius: 12,
display: 'grid',
placeItems: 'center',
background: overlayTheme.iconBg,
color: overlayTheme.iconColor,
fontSize: 18,
flexShrink: 0,
}}
>
<SafetyCertificateOutlined />
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>
</div>
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
使
</div>
</div>
</div>
)}
open={open}
closable={!loading}
maskClosable={!loading}
keyboard={!loading}
onCancel={onPostpone}
width={560}
styles={{
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
body: { paddingTop: 8 },
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },
}}
footer={[
<Button
key="details"
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
type="primary"
ghost
style={actionButtonStyle}
onClick={onViewDetails}
disabled={loading}
>
</Button>,
<Button
key="later"
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
type="primary"
ghost
style={actionButtonStyle}
onClick={onPostpone}
disabled={loading}
>
</Button>,
<Button
key="start"
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
type="primary"
style={actionButtonStyle}
loading={loading}
onClick={onStart}
>
</Button>,
]}
>
<div
style={{
padding: '12px 0 6px',
color: darkMode ? 'rgba(255,255,255,0.82)' : '#2f3b52',
lineHeight: 1.8,
fontSize: 14,
}}
>
使
</div>
</Modal>
);
};
export type { SecurityUpdateIntroModalProps };
export default SecurityUpdateIntroModal;

View File

@@ -0,0 +1,69 @@
import { Modal, Spin } from 'antd';
import { SafetyCertificateOutlined } from '@ant-design/icons';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import {
SECURITY_UPDATE_MODAL_CLASS,
getSecurityUpdateShellSurfaceStyle,
} from '../utils/securityUpdateVisuals';
interface SecurityUpdateProgressModalProps {
open: boolean;
stageText: string;
detailText?: string;
overlayTheme: OverlayWorkbenchTheme;
surfaceOpacity?: number;
}
const SecurityUpdateProgressModal = ({
open,
stageText,
detailText,
overlayTheme,
surfaceOpacity = 1,
}: SecurityUpdateProgressModalProps) => {
return (
<Modal
rootClassName={SECURITY_UPDATE_MODAL_CLASS}
open={open}
closable={false}
maskClosable={false}
keyboard={false}
footer={null}
width={420}
centered
styles={{
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
header: { display: 'none' },
body: { padding: 28 },
}}
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 16 }}>
<div
style={{
width: 52,
height: 52,
borderRadius: 18,
display: 'grid',
placeItems: 'center',
background: overlayTheme.iconBg,
color: overlayTheme.iconColor,
fontSize: 22,
}}
>
<SafetyCertificateOutlined />
</div>
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>
{stageText}
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{detailText ?? '更新过程中会保留当前可用配置,请稍候。'}
</div>
<Spin size="large" />
</div>
</Modal>
);
};
export type { SecurityUpdateProgressModalProps };
export default SecurityUpdateProgressModal;

View File

@@ -0,0 +1,337 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Empty, Modal, Tag } from 'antd';
import { SafetyCertificateOutlined } from '@ant-design/icons';
import type { SecurityUpdateIssue, SecurityUpdateStatus } from '../types';
import {
getSecurityUpdateIssueActionMeta,
getSecurityUpdateIssueSeverityMeta,
getSecurityUpdateItemStatusMeta,
getSecurityUpdateStatusMeta,
sortSecurityUpdateIssues,
} from '../utils/securityUpdatePresentation';
import {
hasSecurityUpdateRecentResult,
resolveSecurityUpdateFocusState,
type SecurityUpdateFocusState,
type SecurityUpdateSettingsFocusTarget,
} from '../utils/securityUpdateRepairFlow';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import {
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
SECURITY_UPDATE_MODAL_CLASS,
SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS,
SECURITY_UPDATE_RESULT_CARD_CLASS,
getSecurityUpdateActionButtonStyle,
getSecurityUpdateSectionSurfaceStyle,
getSecurityUpdateShellSurfaceStyle,
} from '../utils/securityUpdateVisuals';
interface SecurityUpdateSettingsModalProps {
open: boolean;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
surfaceOpacity?: number;
status: SecurityUpdateStatus;
focusTarget?: SecurityUpdateSettingsFocusTarget | null;
focusRequest?: number;
onClose: () => void;
onStart: () => void;
onRetry: () => void;
onRestart: () => void;
onIssueAction: (issue: SecurityUpdateIssue) => void;
}
const sectionStyle = (
overlayTheme: OverlayWorkbenchTheme,
surfaceOpacity: number,
options?: { emphasized?: boolean },
) => ({
borderRadius: 14,
padding: 16,
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, {
...options,
surfaceOpacity,
}),
});
const EMPTY_FOCUS_STATE: SecurityUpdateFocusState = {
target: null,
pulseKey: null,
};
const SecurityUpdateSettingsModal = ({
open,
darkMode,
overlayTheme,
surfaceOpacity = 1,
status,
focusTarget = null,
focusRequest = 0,
onClose,
onStart,
onRetry,
onRestart,
onIssueAction,
}: SecurityUpdateSettingsModalProps) => {
const statusMeta = getSecurityUpdateStatusMeta(status);
const sortedIssues = sortSecurityUpdateIssues(status.issues);
const showRecentResult = hasSecurityUpdateRecentResult(status);
const showStart = status.overallStatus === 'pending' || status.overallStatus === 'postponed';
const showRetry = status.overallStatus === 'needs_attention';
const showRestart = status.overallStatus === 'needs_attention' || status.overallStatus === 'rolled_back';
const actionButtonStyle = getSecurityUpdateActionButtonStyle();
const [activeFocus, setActiveFocus] = useState<SecurityUpdateFocusState>(EMPTY_FOCUS_STATE);
const statusSectionRef = useRef<HTMLDivElement | null>(null);
const recentResultRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const nextFocus = resolveSecurityUpdateFocusState(open, focusTarget, focusRequest);
if (!nextFocus.target || !nextFocus.pulseKey) {
setActiveFocus(EMPTY_FOCUS_STATE);
return undefined;
}
const targetNode = nextFocus.target === 'recent_result'
? recentResultRef.current
: statusSectionRef.current;
if (!targetNode) {
return undefined;
}
setActiveFocus(EMPTY_FOCUS_STATE);
const animationFrame = window.requestAnimationFrame(() => {
targetNode.scrollIntoView({
block: 'nearest',
behavior: 'smooth',
});
targetNode.focus({ preventScroll: true });
setActiveFocus(nextFocus);
});
const highlightTimer = window.setTimeout(() => {
setActiveFocus((current) => (
current.pulseKey === nextFocus.pulseKey ? EMPTY_FOCUS_STATE : current
));
}, 1800);
return () => {
window.cancelAnimationFrame(animationFrame);
window.clearTimeout(highlightTimer);
};
}, [focusRequest, focusTarget, open]);
return (
<Modal
rootClassName={SECURITY_UPDATE_MODAL_CLASS}
title={(
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div
style={{
width: 38,
height: 38,
borderRadius: 12,
display: 'grid',
placeItems: 'center',
background: overlayTheme.iconBg,
color: overlayTheme.iconColor,
fontSize: 18,
flexShrink: 0,
}}
>
<SafetyCertificateOutlined />
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>
</div>
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
</div>
</div>
</div>
)}
open={open}
onCancel={onClose}
footer={[
showRetry ? (
<Button key="retry" className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={onRetry}>
</Button>
) : null,
showRestart ? (
<Button key="restart" className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={onRestart}>
</Button>
) : null,
showStart ? (
<Button
key="start"
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
style={actionButtonStyle}
type="primary"
onClick={onStart}
>
</Button>
) : null,
<Button key="close" className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={onClose}>
</Button>,
]}
width={760}
styles={{
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
body: { paddingTop: 8, maxHeight: 640, overflowY: 'auto' },
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },
}}
>
<div style={{ display: 'grid', gap: 14, padding: '12px 0' }}>
<div
ref={statusSectionRef}
tabIndex={-1}
style={sectionStyle(overlayTheme, surfaceOpacity, { emphasized: activeFocus.target === 'status' })}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: overlayTheme.titleText }}>
{statusMeta.label}
</div>
<div style={{ marginTop: 6, fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{statusMeta.description}
</div>
</div>
<Tag color={
statusMeta.tone === 'success'
? 'success'
: statusMeta.tone === 'error'
? 'error'
: statusMeta.tone === 'processing'
? 'processing'
: statusMeta.tone === 'warning'
? 'warning'
: 'default'
}>
{statusMeta.label}
</Tag>
</div>
</div>
<div style={sectionStyle(overlayTheme, surfaceOpacity)}>
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 12 }}>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 10 }}>
{[
{ label: '总计', value: status.summary.total },
{ label: '已更新', value: status.summary.updated },
{ label: '待处理', value: status.summary.pending },
{ label: '已跳过', value: status.summary.skipped },
{ label: '失败', value: status.summary.failed },
].map((item) => (
<div
key={item.label}
style={{
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, { surfaceOpacity }),
borderRadius: 12,
padding: '12px 10px',
}}
>
<div style={{ fontSize: 12, color: overlayTheme.mutedText }}>{item.label}</div>
<div style={{ marginTop: 6, fontSize: 20, fontWeight: 700, color: overlayTheme.titleText }}>{item.value}</div>
</div>
))}
</div>
</div>
<div style={sectionStyle(overlayTheme, surfaceOpacity)}>
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 12 }}>
</div>
{sortedIssues.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="当前没有待处理项"
/>
) : (
<div style={{ display: 'grid', gap: 10 }}>
{sortedIssues.map((issue) => {
const actionMeta = getSecurityUpdateIssueActionMeta(issue);
const itemStatusMeta = getSecurityUpdateItemStatusMeta(issue.status);
const issueSeverityMeta = getSecurityUpdateIssueSeverityMeta(issue.severity);
return (
<div
key={issue.id}
style={{
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, { surfaceOpacity }),
borderRadius: 12,
padding: 14,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 16,
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText }}>
{issue.title || issue.message || issue.id}
</div>
<Tag color={itemStatusMeta.color}>
{itemStatusMeta.label}
</Tag>
<Tag color={issueSeverityMeta.color}>
{issueSeverityMeta.label}
</Tag>
</div>
<div style={{ marginTop: 6, fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{issue.message || '当前项需要进一步处理后才能完成安全更新。'}
</div>
</div>
<Button
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
style={actionButtonStyle}
type={actionMeta.emphasis === 'primary' ? 'primary' : 'default'}
onClick={() => onIssueAction(issue)}
>
{actionMeta.label}
</Button>
</div>
);
})}
</div>
)}
</div>
{showRecentResult ? (
<div
ref={recentResultRef}
tabIndex={-1}
className={[
SECURITY_UPDATE_RESULT_CARD_CLASS,
activeFocus.target === 'recent_result' ? SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS : '',
].filter(Boolean).join(' ')}
style={sectionStyle(overlayTheme, surfaceOpacity, { emphasized: activeFocus.target === 'recent_result' })}
>
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 8 }}>
</div>
{status.backupPath ? (
<div style={{ fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
<span style={{ color: overlayTheme.titleText }}>{status.backupPath}</span>
</div>
) : null}
{status.lastError ? (
<div style={{ marginTop: 8, fontSize: 13, color: '#ff7875', lineHeight: 1.7 }}>
{status.lastError}
</div>
) : null}
</div>
) : null}
</div>
</Modal>
);
};
export type { SecurityUpdateSettingsModalProps };
export default SecurityUpdateSettingsModal;

File diff suppressed because it is too large Load Diff

View File

@@ -16,43 +16,40 @@ import RedisMonitor from './RedisMonitor';
import TriggerViewer from './TriggerViewer';
import DefinitionViewer from './DefinitionViewer';
import TableOverview from './TableOverview';
import JVMOverview from './JVMOverview';
import JVMResourceBrowser from './JVMResourceBrowser';
import JVMAuditViewer from './JVMAuditViewer';
import JVMDiagnosticConsole from './JVMDiagnosticConsole';
import JVMMonitoringDashboard from './JVMMonitoringDashboard';
import type { TabData } from '../types';
const detectConnectionEnvLabel = (connectionName: string): string | null => {
const tokens = connectionName.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
if (tokens.includes('prod') || tokens.includes('production')) return 'PROD';
if (tokens.includes('uat')) return 'UAT';
if (tokens.includes('dev') || tokens.includes('development')) return 'DEV';
if (tokens.includes('sit')) return 'SIT';
if (tokens.includes('stg') || tokens.includes('stage') || tokens.includes('staging') || tokens.includes('pre')) return 'STG';
if (tokens.includes('test') || tokens.includes('qa')) return 'TEST';
return null;
};
const buildTabDisplayTitle = (tab: TabData, connectionName: string | undefined): string => {
if (tab.type !== 'table' && tab.type !== 'design' && tab.type !== 'table-overview') return tab.title;
if (!connectionName) return tab.title;
const prefix = detectConnectionEnvLabel(connectionName) || connectionName;
return `[${prefix}] ${tab.title}`;
};
import { buildTabDisplayTitle } from '../utils/tabDisplay';
import { resolveConnectionAccentColor } from '../utils/connectionVisual';
type SortableTabLabelProps = {
displayTitle: string;
menuItems: MenuProps['items'];
accentColor?: string;
};
const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
displayTitle,
menuItems,
accentColor,
}) => {
const labelStyle = accentColor
? ({ '--connection-accent': accentColor } as React.CSSProperties)
: undefined;
return (
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<span
className="tab-dnd-label"
className={`tab-dnd-label${accentColor ? ' has-connection-accent' : ''}`}
onContextMenu={(e) => e.preventDefault()}
title="拖拽调整标签顺序"
title={displayTitle}
style={labelStyle}
>
{displayTitle}
{accentColor ? <span className="tab-connection-accent" aria-hidden="true" /> : null}
<span className="tab-title-text">{displayTitle}</span>
</span>
</Dropdown>
);
@@ -198,8 +195,9 @@ const TabManager: React.FC = () => {
);
const items = useMemo(() => tabs.map((tab, index) => {
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
const displayTitle = buildTabDisplayTitle(tab, connectionName);
const connection = connections.find((conn) => conn.id === tab.connectionId);
const displayTitle = buildTabDisplayTitle(tab, connection);
const accentColor = connection ? resolveConnectionAccentColor(connection) : undefined;
const tabIsActive = tab.id === activeTabId;
let content;
if (tab.type === 'query') {
@@ -220,6 +218,16 @@ const TabManager: React.FC = () => {
content = <DefinitionViewer tab={tab} />;
} else if (tab.type === 'table-overview') {
content = <TableOverview tab={tab} />;
} else if (tab.type === 'jvm-overview') {
content = <JVMOverview tab={tab} />;
} else if (tab.type === 'jvm-resource') {
content = <JVMResourceBrowser tab={tab} />;
} else if (tab.type === 'jvm-audit') {
content = <JVMAuditViewer tab={tab} />;
} else if (tab.type === 'jvm-diagnostic') {
content = <JVMDiagnosticConsole tab={tab} />;
} else if (tab.type === 'jvm-monitoring') {
content = <JVMMonitoringDashboard tab={tab} />;
}
const menuItems: MenuProps['items'] = [
@@ -255,6 +263,7 @@ const TabManager: React.FC = () => {
<SortableTabLabel
displayTitle={displayTitle}
menuItems={menuItems}
accentColor={accentColor}
/>
),
key: tab.id,
@@ -319,8 +328,26 @@ const TabManager: React.FC = () => {
-webkit-user-select: none;
display: inline-flex;
align-items: center;
gap: 7px;
max-width: 100%;
}
.main-tabs .tab-dnd-label.has-connection-accent {
position: relative;
}
.main-tabs .tab-connection-accent {
width: 9px;
height: 9px;
border-radius: 999px;
background: var(--connection-accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--connection-accent) 22%, transparent);
flex: 0 0 auto;
}
.main-tabs .tab-title-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.main-tabs .tab-dnd-node.is-dragging,
.main-tabs .tab-dnd-node.is-dragging .tab-dnd-label {
cursor: grabbing !important;
@@ -337,6 +364,10 @@ const TabManager: React.FC = () => {
box-shadow: 0 0 0 2px rgba(9, 109, 217, 0.32);
background: rgba(9, 109, 217, 0.08);
}
body[data-theme='light'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
background: rgba(24, 144, 255, 0.10) !important;
border-color: rgba(24, 144, 255, 0.28) !important;
}
body[data-theme='dark'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
background: rgba(255, 214, 102, 0.12) !important;
border-color: rgba(255, 214, 102, 0.4) !important;

View File

@@ -9,8 +9,20 @@ import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, Trigg
import { useStore } from '../store';
import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App';
import { hasIndexFormChanged, normalizeIndexFormFromRow, shouldRestoreOriginalIndex, toggleIndexSelection as getNextIndexSelection, type IndexDisplaySnapshot } from './tableDesignerIndexUtils';
import { buildAlterTablePreviewSql } from './tableDesignerSchemaSql';
import { buildAlterTablePreviewSql, buildCreateTablePreviewSql, hasAlterTableDraftChanges } from './tableDesignerSchemaSql';
import TableDesignerSqlPreview from './TableDesignerSqlPreview';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import {
isMysqlFamilyDialect as isMysqlFamilySqlDialect,
isOracleLikeDialect as isOracleLikeSqlDialect,
isPgLikeDialect as isPgLikeSqlDialect,
isSqlServerDialect as isSqlServerSqlDialect,
quoteSqlIdentifierPart,
quoteSqlIdentifierPath,
resolveColumnTypeOptions,
resolveSqlDialect,
} from '../utils/sqlDialect';
interface EditableColumn extends ColumnDefinition {
_key: string;
@@ -539,6 +551,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
// Initial Columns Definition
useEffect(() => {
const columnTypeOptions = resolveColumnTypeOptions(getDbType());
const initialCols = [
{
title: '名',
@@ -546,7 +559,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
key: 'name',
width: 180,
render: (text: string, record: EditableColumn) => readOnly ? text : (
<Input value={text} onChange={e => handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" />
<Input {...noAutoCapInputProps} value={text} onChange={e => handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" />
)
},
{
@@ -555,7 +568,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
key: 'type',
width: 150,
render: (text: string, record: EditableColumn) => readOnly ? text : (
<AutoComplete options={DB_TYPE_OPTIONS[getDbType()] || COMMON_TYPES} value={text} onChange={val => handleColumnChange(record._key, 'type', val)} style={{ width: '100%' }} variant="borderless" />
<AutoComplete options={columnTypeOptions} value={text} onChange={val => handleColumnChange(record._key, 'type', val)} style={{ width: '100%' }} variant="borderless" />
)
},
{
@@ -635,7 +648,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
}])
];
setTableColumns(initialCols);
}, [readOnly]); // Re-create if readOnly changes
}, [connections, openCommentEditor, readOnly, tab.connectionId]); // Re-create when datasource dialect or readonly state changes
const flushResizeGhost = useCallback(() => {
resizeRafRef.current = null;
@@ -823,6 +836,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
if (normalized === 'postgresql' || normalized === 'pg') return 'postgres';
if (normalized === 'mssql' || normalized === 'sql_server' || normalized === 'sql-server') return 'sqlserver';
if (normalized === 'doris') return 'diros';
if (normalized === 'open_gauss' || normalized === 'open-gauss') return 'opengauss';
return normalized;
};
@@ -846,16 +860,11 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
const getDbType = (): string => {
const conn = connections.find(c => c.id === tab.connectionId);
const type = normalizeDbType(String(conn?.config?.type || ''));
if (!type) return '';
if (type === 'custom') {
return inferDialectFromCustomDriver(String(conn?.config?.driver || ''));
}
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
const rawType = String(conn?.config?.type || '').trim();
if (!rawType) return '';
return resolveSqlDialect(rawType, String(conn?.config?.driver || ''), {
oceanBaseProtocol: conn?.config?.oceanBaseProtocol,
});
};
const generateTriggerTemplate = (): string => {
@@ -864,6 +873,9 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
switch (dbType) {
case 'mysql':
case 'mariadb':
case 'oceanbase':
case 'diros':
return `CREATE TRIGGER trigger_name
BEFORE INSERT ON \`${tblName}\`
FOR EACH ROW
@@ -874,6 +886,7 @@ END;`;
case 'kingbase':
case 'highgo':
case 'vastbase':
case 'opengauss':
return `CREATE OR REPLACE FUNCTION trigger_function_name()
RETURNS TRIGGER AS $$
BEGIN
@@ -896,6 +909,7 @@ BEGIN
-- 触发器逻辑
END;`;
case 'oracle':
case 'dameng':
case 'dm':
return `CREATE OR REPLACE TRIGGER trigger_name
BEFORE INSERT ON "${tblName}"
@@ -921,15 +935,20 @@ END;`;
switch (dbType) {
case 'mysql':
case 'mariadb':
case 'oceanbase':
case 'diros':
return `DROP TRIGGER IF EXISTS \`${triggerName}\``;
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase':
case 'opengauss':
return `DROP TRIGGER IF EXISTS "${triggerName}" ON "${tblName}"`;
case 'sqlserver':
return `DROP TRIGGER IF EXISTS [${triggerName}]`;
case 'oracle':
case 'dameng':
case 'dm':
return `DROP TRIGGER "${triggerName}"`;
case 'sqlite':
@@ -1333,36 +1352,20 @@ ${selectedTrigger.statement}`;
};
};
const isPgLikeDialect = (dbType: string): boolean =>
dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase';
const isOracleLikeDialect = (dbType: string): boolean => dbType === 'oracle' || dbType === 'dm';
const isSqlServerDialect = (dbType: string): boolean => dbType === 'sqlserver';
const isMysqlLikeDialect = (dbType: string): boolean => dbType === 'mysql';
const isPgLikeDialect = (dbType: string): boolean => isPgLikeSqlDialect(dbType);
const isOracleLikeDialect = (dbType: string): boolean => isOracleLikeSqlDialect(dbType);
const isSqlServerDialect = (dbType: string): boolean => isSqlServerSqlDialect(dbType);
const isMysqlLikeDialect = (dbType: string): boolean => isMysqlFamilySqlDialect(dbType);
const isNonRelationalDialect = (dbType: string): boolean => dbType === 'redis' || dbType === 'mongodb';
const lacksAlterForeignKeySupport = (dbType: string): boolean => dbType === 'sqlite' || dbType === 'duckdb' || dbType === 'tdengine';
const lacksTableCommentSupport = (dbType: string): boolean => dbType === 'sqlite';
const quoteIdentifierPartByDialect = (part: string, dbType: string): string => {
const ident = stripIdentifierQuotes(part);
if (!ident) return '';
if (isMysqlLikeDialect(dbType) || dbType === 'tdengine') {
return `\`${escapeBacktickIdentifier(ident)}\``;
}
if (isSqlServerDialect(dbType)) {
return `[${escapeBracketIdentifier(ident)}]`;
}
return `"${escapeDoubleQuoteIdentifier(ident)}"`;
return quoteSqlIdentifierPart(dbType, part);
};
const quoteIdentifierPathByDialect = (path: string, dbType: string): string => {
const raw = String(path || '').trim();
if (!raw) return '';
const parts = raw
.split('.')
.map(part => stripIdentifierQuotes(part))
.filter(Boolean);
if (parts.length === 0) return '';
return parts.map(part => quoteIdentifierPartByDialect(part, dbType)).join('.');
return quoteSqlIdentifierPath(dbType, path);
};
const resolveTableInfo = () => {
@@ -1395,6 +1398,19 @@ ${selectedTrigger.statement}`;
};
};
const hasUnsavedDraftChanges = useMemo(() => {
if (isNewTable || readOnly) {
return false;
}
const tableInfo = resolveTableInfo();
return hasAlterTableDraftChanges({
dbType: tableInfo.dbType,
tableName: tableInfo.qualifiedName,
originalColumns,
columns,
});
}, [columns, connections, isNewTable, originalColumns, readOnly, tab.connectionId, tab.dbName, tab.tableName]);
const supportsIndexSchemaOps = (): boolean => {
const dbType = getDbType();
if (!dbType) return false;
@@ -1467,19 +1483,13 @@ ${selectedTrigger.statement}`;
};
const buildCreateTableSql = (targetTableName: string, targetColumns: EditableColumn[], targetCharset: string, targetCollation: string) => {
const tableName = `\`${escapeBacktickIdentifier(targetTableName)}\``;
const colDefs = targetColumns.map(curr => {
let extra = curr.extra || "";
if (curr.isAutoIncrement && !extra.toLowerCase().includes('auto_increment')) {
extra += " AUTO_INCREMENT";
}
return `\`${escapeBacktickIdentifier(curr.name)}\` ${curr.type} ${curr.nullable === 'NO' ? 'NOT NULL' : 'NULL'} ${curr.default ? `DEFAULT '${escapeSqlString(String(curr.default))}'` : ''} ${extra} COMMENT '${escapeSqlString(curr.comment || '')}'`;
return buildCreateTablePreviewSql({
dbType: getDbType(),
tableName: targetTableName,
columns: targetColumns,
charset: targetCharset,
collation: targetCollation,
});
const pks = targetColumns.filter(c => c.key === 'PRI').map(c => `\`${escapeBacktickIdentifier(c.name)}\``);
if (pks.length > 0) {
colDefs.push(`PRIMARY KEY (${pks.join(', ')})`);
}
return `CREATE TABLE ${tableName} (\n ${colDefs.join(",\n ")}\n) ENGINE=InnoDB DEFAULT CHARSET=${targetCharset} COLLATE=${targetCollation};`;
};
const openCopySelectedColumnsModal = () => {
@@ -2142,6 +2152,24 @@ END;`;
}
};
const handleRefreshDesigner = () => {
if (!hasUnsavedDraftChanges) {
void fetchData();
return;
}
Modal.confirm({
title: '存在未保存的字段变更',
icon: <ExclamationCircleOutlined />,
content: '刷新后会丢失当前尚未保存的字段调整,是否仍要刷新并覆盖当前草稿?',
okText: '仍然刷新',
cancelText: '取消',
onOk: async () => {
await fetchData();
},
});
};
const handleExecuteSave = async () => {
const result = await executeSchemaStatements(previewSql);
if (!result.ok) {
@@ -2492,6 +2520,7 @@ END;`;
{isNewTable && (
<>
<Input
{...noAutoCapInputProps}
placeholder="请输入表名"
value={newTableName}
onChange={e => setNewTableName(e.target.value)}
@@ -2517,7 +2546,7 @@ END;`;
</>
)}
{!readOnly && <Button size="small" icon={<SaveOutlined />} type="primary" onClick={generateDDL}></Button>}
{!isNewTable && <Button size="small" icon={<ReloadOutlined />} onClick={fetchData}></Button>}
{!isNewTable && <Button size="small" icon={<ReloadOutlined />} onClick={handleRefreshDesigner}></Button>}
{!isNewTable && !readOnly && supportsTableCommentOps() && (
<Button size="small" icon={<EditOutlined />} onClick={openTableCommentModal}></Button>
)}
@@ -2805,6 +2834,7 @@ END;`;
{selectedColumns.length}
</div>
<Input
{...noAutoCapInputProps}
placeholder="请输入目标表名"
value={copyTableName}
onChange={e => setCopyTableName(e.target.value)}
@@ -2865,6 +2895,7 @@ END;`;
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Input
{...noAutoCapInputProps}
placeholder={indexForm.kind === 'PRIMARY' ? '主键索引固定名称PRIMARY' : '索引名(例如 idx_user_name'}
value={indexForm.name}
onChange={(e) => setIndexForm(prev => ({ ...prev, name: e.target.value }))}
@@ -2934,6 +2965,7 @@ END;`;
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Input
{...noAutoCapInputProps}
placeholder="外键约束名(例如 fk_order_user"
value={foreignKeyForm.constraintName}
onChange={(e) => setForeignKeyForm(prev => ({ ...prev, constraintName: e.target.value }))}
@@ -2949,6 +2981,7 @@ END;`;
style={{ width: '100%' }}
/>
<Input
{...noAutoCapInputProps}
placeholder="参考表(支持 db.table"
value={foreignKeyForm.refTableName}
onChange={(e) => setForeignKeyForm(prev => ({ ...prev, refTableName: e.target.value }))}
@@ -2977,11 +3010,7 @@ END;`;
okText="执行"
cancelText="取消"
>
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
<pre style={{ background: darkMode ? '#1e1e1e' : '#f5f5f5', color: darkMode ? '#d4d4d4' : 'inherit', padding: '10px', borderRadius: '4px', border: darkMode ? '1px solid #333' : '1px solid #eee', whiteSpace: 'pre-wrap' }}>
{previewSql}
</pre>
</div>
<TableDesignerSqlPreview sql={previewSql} darkMode={darkMode} />
<p style={{ marginTop: 10, color: '#faad14' }}> SQL</p>
</Modal>

View File

@@ -0,0 +1,187 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import TableDesignerSqlPreview, { resolveSqlChangeHighlights } from './TableDesignerSqlPreview';
const mockMonaco = {
Range: class {
startLineNumber: number;
startColumn: number;
endLineNumber: number;
endColumn: number;
constructor(
startLineNumber: number,
startColumn: number,
endLineNumber: number,
endColumn: number,
) {
this.startLineNumber = startLineNumber;
this.startColumn = startColumn;
this.endLineNumber = endLineNumber;
this.endColumn = endColumn;
}
},
editor: {
defineTheme: vi.fn(),
},
};
const mockEditor = {
deltaDecorations: vi.fn(() => ['decoration-1']),
getModel: vi.fn(() => ({
getLineCount: () => 5,
getLineMaxColumn: (lineNumber: number) => (lineNumber === 1 ? 22 : 80),
})),
};
vi.mock('@monaco-editor/react', () => ({
default: ({
beforeMount,
defaultLanguage,
language,
onMount,
options,
theme,
value,
}: {
beforeMount?: (monaco: any) => void;
defaultLanguage?: string;
language?: string;
onMount?: (editor: any, monaco: any) => void;
options?: Record<string, any>;
theme?: string;
value?: string;
}) => {
beforeMount?.(mockMonaco);
onMount?.(mockEditor, mockMonaco);
return (
<div
data-default-language={defaultLanguage}
data-language={language}
data-monaco-editor-mock="true"
data-options={JSON.stringify(options)}
data-theme={theme}
>
{value}
</div>
);
},
}));
describe('TableDesignerSqlPreview', () => {
beforeEach(() => {
mockEditor.deltaDecorations.mockClear();
mockMonaco.editor.defineTheme.mockClear();
});
it('renders SQL changes in a read-only Monaco SQL editor with explicit syntax highlight theme', () => {
const markup = renderToStaticMarkup(
<TableDesignerSqlPreview
sql={'ALTER TABLE "users"\nRENAME COLUMN "name" TO "display_name";'}
darkMode={false}
/>,
);
expect(markup).toContain('data-table-designer-sql-preview="true"');
expect(markup).toContain('data-monaco-editor-mock="true"');
expect(markup).toContain('data-default-language="sql"');
expect(markup).toContain('data-language="sql"');
expect(markup).toContain('data-theme="gonavi-sql-preview-light"');
expect(markup).toContain('&quot;readOnly&quot;:true');
expect(markup).toContain('&quot;lineNumbers&quot;:&quot;on&quot;');
expect(markup).not.toContain('&quot;glyphMargin&quot;:true');
expect(markup).toContain('ALTER TABLE');
expect(markup).toContain('RENAME COLUMN');
expect(mockMonaco.editor.defineTheme).toHaveBeenCalledWith(
'gonavi-sql-preview-light',
expect.objectContaining({
base: 'vs',
inherit: true,
rules: expect.arrayContaining([
expect.objectContaining({ token: 'keyword', foreground: expect.any(String) }),
expect.objectContaining({ token: 'string', foreground: expect.any(String) }),
expect.objectContaining({ token: 'comment', foreground: expect.any(String) }),
]),
}),
);
});
it('detects only SQL change operation lines instead of highlighting the whole SQL block', () => {
const highlights = resolveSqlChangeHighlights([
'ALTER TABLE "users"',
'ADD COLUMN "age" int NULL;',
'ALTER TABLE "users"',
'RENAME COLUMN "name" TO "display_name";',
'-- DuckDB 不支持通过 COMMENT ON COLUMN 持久化字段备注',
].join('\n'));
expect(highlights).toEqual([
expect.objectContaining({ kind: 'add', lineNumber: 2 }),
expect.objectContaining({ kind: 'rename', lineNumber: 4 }),
]);
});
it('adds Monaco decorations to changed SQL lines only', () => {
renderToStaticMarkup(
<TableDesignerSqlPreview
sql={[
'ALTER TABLE "users"',
'ADD COLUMN "age" int NULL;',
'ALTER TABLE "users"',
'DROP COLUMN "legacy_name";',
].join('\n')}
/>,
);
expect(mockEditor.deltaDecorations).toHaveBeenCalledWith(
[],
expect.arrayContaining([
expect.objectContaining({
range: expect.objectContaining({ startLineNumber: 2, endLineNumber: 2 }),
options: expect.objectContaining({
className: expect.stringContaining('gonavi-sql-preview-change-line-add'),
isWholeLine: true,
linesDecorationsClassName: expect.stringContaining('gonavi-sql-preview-change-marker-add'),
}),
}),
expect.objectContaining({
range: expect.objectContaining({ startLineNumber: 4, endLineNumber: 4 }),
options: expect.objectContaining({
className: expect.stringContaining('gonavi-sql-preview-change-line-drop'),
isWholeLine: true,
linesDecorationsClassName: expect.stringContaining('gonavi-sql-preview-change-marker-drop'),
}),
}),
]),
);
const firstDecorationCall = mockEditor.deltaDecorations.mock.calls[0] as unknown as [unknown, unknown[]];
expect(firstDecorationCall[1]).toHaveLength(2);
expect(firstDecorationCall[1]).toEqual(
expect.arrayContaining([
expect.objectContaining({
options: expect.not.objectContaining({
glyphMarginClassName: expect.any(String),
}),
}),
]),
);
});
it('uses the dark SQL preview theme when dark mode is enabled', () => {
const markup = renderToStaticMarkup(
<TableDesignerSqlPreview sql="CREATE TABLE users (id int);" darkMode />,
);
expect(markup).toContain('data-theme="gonavi-sql-preview-dark"');
expect(mockMonaco.editor.defineTheme).toHaveBeenCalledWith(
'gonavi-sql-preview-dark',
expect.objectContaining({
base: 'vs-dark',
inherit: true,
}),
);
});
});

View File

@@ -0,0 +1,250 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import Editor, { type BeforeMount, type OnMount } from '@monaco-editor/react';
interface TableDesignerSqlPreviewProps {
sql: string;
darkMode?: boolean;
height?: string | number;
}
export type SqlChangeHighlightKind =
| 'add'
| 'comment'
| 'constraint'
| 'create'
| 'drop'
| 'modify'
| 'rename';
export interface SqlChangeHighlight {
line: string;
lineNumber: number;
kind: SqlChangeHighlightKind;
label: string;
}
const SQL_PREVIEW_LIGHT_THEME = 'gonavi-sql-preview-light';
const SQL_PREVIEW_DARK_THEME = 'gonavi-sql-preview-dark';
const CHANGE_LINE_RULES: Array<{
kind: SqlChangeHighlightKind;
label: string;
pattern: RegExp;
}> = [
{ kind: 'rename', label: '重命名变更', pattern: /\b(RENAME\s+COLUMN|CHANGE\s+COLUMN|RENAME\s+TO|SP_RENAME)\b/i },
{ kind: 'add', label: '新增变更', pattern: /\b(ADD\s+COLUMN|ADD\s+PRIMARY\s+KEY)\b/i },
{ kind: 'drop', label: '删除变更', pattern: /\b(DROP\s+COLUMN|DROP\s+PRIMARY\s+KEY)\b/i },
{ kind: 'modify', label: '字段属性变更', pattern: /\b(MODIFY\s+COLUMN|ALTER\s+COLUMN|SET\s+DATA\s+TYPE|SET\s+DEFAULT|DROP\s+DEFAULT|SET\s+NOT\s+NULL|DROP\s+NOT\s+NULL)\b/i },
{ kind: 'constraint', label: '约束变更', pattern: /\b(ADD\s+CONSTRAINT|DROP\s+CONSTRAINT)\b/i },
{ kind: 'comment', label: '备注变更', pattern: /\b(COMMENT\s+ON\s+COLUMN|COMMENT\s+ON\s+TABLE)\b/i },
];
const CREATE_TABLE_PATTERN = /^\s*CREATE\s+TABLE\b/i;
const getCreateTableLineHighlight = (line: string, lineNumber: number): SqlChangeHighlight | null => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('--')) return null;
return {
line,
lineNumber,
kind: 'create',
label: '新建表结构',
};
};
const getAlterLineHighlight = (line: string, lineNumber: number): SqlChangeHighlight | null => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('--')) return null;
const matchedRule = CHANGE_LINE_RULES.find((rule) => rule.pattern.test(trimmed));
if (!matchedRule) return null;
return {
line,
lineNumber,
kind: matchedRule.kind,
label: matchedRule.label,
};
};
export const resolveSqlChangeHighlights = (sql: string): SqlChangeHighlight[] => {
const lines = sql.split(/\r?\n/);
const isCreateTableSql = lines.some((line) => CREATE_TABLE_PATTERN.test(line));
return lines
.map((line, index) => (
isCreateTableSql
? getCreateTableLineHighlight(line, index + 1)
: getAlterLineHighlight(line, index + 1)
))
.filter((highlight): highlight is SqlChangeHighlight => Boolean(highlight));
};
const registerSqlPreviewThemes: BeforeMount = (monaco) => {
monaco.editor.defineTheme(SQL_PREVIEW_LIGHT_THEME, {
base: 'vs',
inherit: true,
rules: [
{ token: 'keyword', foreground: '006C9C', fontStyle: 'bold' },
{ token: 'operator', foreground: '8250DF' },
{ token: 'number', foreground: 'B45309' },
{ token: 'string', foreground: '15803D' },
{ token: 'comment', foreground: '64748B', fontStyle: 'italic' },
{ token: 'predefined', foreground: '0F766E' },
],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#0F172A0A',
'editorGutter.background': '#00000000',
'editorLineNumber.foreground': '#94A3B8',
},
});
monaco.editor.defineTheme(SQL_PREVIEW_DARK_THEME, {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'keyword', foreground: '7DD3FC', fontStyle: 'bold' },
{ token: 'operator', foreground: 'C4B5FD' },
{ token: 'number', foreground: 'FDBA74' },
{ token: 'string', foreground: '86EFAC' },
{ token: 'comment', foreground: '94A3B8', fontStyle: 'italic' },
{ token: 'predefined', foreground: '5EEAD4' },
],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#FFFFFF12',
'editorGutter.background': '#00000000',
'editorLineNumber.foreground': '#64748B',
},
});
};
const getLineDecorationClassName = (kind: SqlChangeHighlightKind): string =>
`gonavi-sql-preview-change-line gonavi-sql-preview-change-line-${kind}`;
const getLineDecorationMarkerClassName = (kind: SqlChangeHighlightKind): string =>
`gonavi-sql-preview-change-marker gonavi-sql-preview-change-marker-${kind}`;
const TableDesignerSqlPreview: React.FC<TableDesignerSqlPreviewProps> = ({
sql,
darkMode = false,
height = '360px',
}) => {
const decorationIdsRef = useRef<string[]>([]);
const editorRef = useRef<any>(null);
const monacoRef = useRef<any>(null);
const changeHighlights = useMemo(() => resolveSqlChangeHighlights(sql), [sql]);
const applyChangeDecorations = useCallback(() => {
const editor = editorRef.current;
const monaco = monacoRef.current;
const model = editor?.getModel?.();
if (!editor || !monaco || !model) return;
const lineCount = model.getLineCount();
const decorations = changeHighlights
.filter((highlight) => highlight.lineNumber <= lineCount)
.map((highlight) => {
const endColumn = Math.max(1, model.getLineMaxColumn(highlight.lineNumber));
return {
range: new monaco.Range(highlight.lineNumber, 1, highlight.lineNumber, endColumn),
options: {
className: getLineDecorationClassName(highlight.kind),
hoverMessage: { value: highlight.label },
isWholeLine: true,
linesDecorationsClassName: getLineDecorationMarkerClassName(highlight.kind),
},
};
});
decorationIdsRef.current = editor.deltaDecorations(decorationIdsRef.current, decorations);
}, [changeHighlights]);
const handleEditorMount: OnMount = (editor, monaco) => {
editorRef.current = editor;
monacoRef.current = monaco;
applyChangeDecorations();
};
useEffect(() => {
applyChangeDecorations();
}, [applyChangeDecorations, sql]);
return (
<div
data-table-designer-sql-preview="true"
style={{
maxHeight: 400,
overflow: 'hidden',
borderRadius: 8,
border: darkMode ? '1px solid #333' : '1px solid #eee',
}}
>
<style>
{`
.gonavi-sql-preview-change-line {
border-left: 3px solid transparent;
}
.gonavi-sql-preview-change-line-add,
.gonavi-sql-preview-change-line-create {
background: rgba(22, 163, 74, 0.14);
border-left-color: #16a34a;
}
.gonavi-sql-preview-change-line-drop {
background: rgba(220, 38, 38, 0.14);
border-left-color: #dc2626;
}
.gonavi-sql-preview-change-line-modify,
.gonavi-sql-preview-change-line-rename,
.gonavi-sql-preview-change-line-constraint,
.gonavi-sql-preview-change-line-comment {
background: rgba(217, 119, 6, 0.16);
border-left-color: #d97706;
}
.gonavi-sql-preview-change-marker {
width: 4px !important;
margin-left: 2px;
border-radius: 999px;
}
.gonavi-sql-preview-change-marker-add,
.gonavi-sql-preview-change-marker-create {
background: #16a34a;
}
.gonavi-sql-preview-change-marker-drop {
background: #dc2626;
}
.gonavi-sql-preview-change-marker-modify,
.gonavi-sql-preview-change-marker-rename,
.gonavi-sql-preview-change-marker-constraint,
.gonavi-sql-preview-change-marker-comment {
background: #d97706;
}
`}
</style>
<Editor
beforeMount={registerSqlPreviewThemes}
defaultLanguage="sql"
height={height}
language="sql"
onMount={handleEditorMount}
options={{
automaticLayout: true,
fontFamily: '"JetBrains Mono", "Cascadia Code", Consolas, monospace',
fontSize: 13,
lineNumbers: 'on',
lineDecorationsWidth: 14,
minimap: { enabled: false },
padding: { top: 8, bottom: 8 },
readOnly: true,
scrollBeyondLastLine: false,
wordWrap: 'on',
}}
theme={darkMode ? SQL_PREVIEW_DARK_THEME : SQL_PREVIEW_LIGHT_THEME}
value={sql}
/>
</div>
);
};
export default TableDesignerSqlPreview;

View File

@@ -1,11 +1,22 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal } from 'antd';
import React, { useState, useEffect, useMemo, useCallback, useDeferredValue } from 'react';
import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal, Button } from 'antd';
import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined, AppstoreOutlined, UnorderedListOutlined, WarningOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
import type { TabData } from '../types';
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
import {
TABLE_OVERVIEW_RENDER_BATCH_SIZE,
buildTableOverviewSearchIndex,
filterAndSortTableOverviewRows,
resolveTableOverviewVisibleRows,
type TableOverviewSortField,
type TableOverviewSortOrder,
} from '../utils/tableOverviewFilter';
interface TableOverviewProps {
tab: TabData;
@@ -22,8 +33,8 @@ interface TableStatRow {
updateTime: string;
}
type SortField = 'name' | 'rows' | 'dataSize';
type SortOrder = 'asc' | 'desc';
type SortField = TableOverviewSortField;
type SortOrder = TableOverviewSortOrder;
type ViewMode = 'card' | 'list';
const formatSize = (bytes: number): string => {
@@ -41,27 +52,44 @@ const formatRows = (count: number): string => {
return String(count);
};
const getMetadataDialect = (connType: string, driver?: string): string => {
const getMetadataDialect = (connType: string, driver?: string, oceanBaseProtocol?: string): string => {
const type = (connType || '').trim().toLowerCase();
if (type === 'custom') {
const d = (driver || '').trim().toLowerCase();
if (d === 'diros' || d === 'doris') return 'mysql';
if (d === 'oceanbase') return 'mysql';
if (d === 'opengauss' || d === 'open_gauss' || d === 'open-gauss') return 'opengauss';
return d;
}
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'oceanbase' && String(oceanBaseProtocol || '').trim().toLowerCase() === 'oracle') return 'oracle';
if (type === 'mariadb' || type === 'oceanbase' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
const buildTableStatusSQL = (dialect: string, dbName: string, schemaName?: string): string => {
const escapeLiteral = (s: string) => s.replace(/'/g, "''");
switch (dialect) {
const escapeLiteral = (s: string) => s.replace(/'/g, "''");
switch (dialect) {
case 'mysql':
return `SHOW TABLE STATUS FROM \`${dbName.replace(/`/g, '``')}\``;
return `
SELECT
TABLE_NAME AS table_name,
TABLE_COMMENT AS table_comment,
TABLE_ROWS AS table_rows,
DATA_LENGTH AS data_length,
INDEX_LENGTH AS index_length,
ENGINE AS engine,
CREATE_TIME AS create_time,
UPDATE_TIME AS update_time
FROM information_schema.tables
WHERE table_schema = '${escapeLiteral(dbName)}'
AND table_type = 'BASE TABLE'
ORDER BY table_name`;
case 'postgres':
case 'kingbase':
case 'vastbase':
case 'highgo': {
case 'highgo':
case 'opengauss': {
const schema = schemaName || 'public';
return `
SELECT
@@ -150,8 +178,16 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const [sortField, setSortField] = useState<SortField>('name');
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [visibleTableLimit, setVisibleTableLimit] = useState(TABLE_OVERVIEW_RENDER_BATCH_SIZE);
const deferredSearchText = useDeferredValue(searchText);
const isSearchPending = searchText !== deferredSearchText;
const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
const metadataDialect = useMemo(
() => getMetadataDialect(connection?.config?.type || '', connection?.config?.driver, connection?.config?.oceanBaseProtocol),
[connection?.config?.driver, connection?.config?.oceanBaseProtocol, connection?.config?.type]
);
const autoFetchVisible = useAutoFetchVisibility();
const loadData = useCallback(async () => {
if (!connection) return;
@@ -165,11 +201,10 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
useSSH: connection.config.useSSH || false,
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
};
const dialect = getMetadataDialect(connection.config.type, connection.config.driver);
const sql = buildTableStatusSQL(dialect, tab.dbName || '', (tab as any).schemaName);
const sql = buildTableStatusSQL(metadataDialect, tab.dbName || '', (tab as any).schemaName);
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', sql);
if (res.success && Array.isArray(res.data)) {
setTables(parseTableStats(dialect, res.data));
setTables(parseTableStats(metadataDialect, res.data));
} else {
message.error('获取表信息失败: ' + (res.message || '未知错误'));
}
@@ -178,25 +213,30 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
} finally {
setLoading(false);
}
}, [connection, tab.dbName]);
}, [connection, metadataDialect, tab.dbName]);
useEffect(() => { loadData(); }, [loadData]);
const sortedFiltered = useMemo(() => {
let list = [...tables];
if (searchText.trim()) {
const kw = searchText.trim().toLowerCase();
list = list.filter(t => t.name.toLowerCase().includes(kw) || t.comment.toLowerCase().includes(kw));
useEffect(() => {
if (!autoFetchVisible) {
return;
}
list.sort((a, b) => {
let cmp = 0;
if (sortField === 'name') cmp = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
else if (sortField === 'rows') cmp = a.rows - b.rows;
else if (sortField === 'dataSize') cmp = a.dataSize - b.dataSize;
return sortOrder === 'asc' ? cmp : -cmp;
});
return list;
}, [tables, searchText, sortField, sortOrder]);
void loadData();
}, [autoFetchVisible, loadData]);
const tableSearchIndex = useMemo(() => buildTableOverviewSearchIndex(tables), [tables]);
const sortedFiltered = useMemo(() => (
filterAndSortTableOverviewRows(tableSearchIndex, deferredSearchText, sortField, sortOrder)
), [deferredSearchText, sortField, sortOrder, tableSearchIndex]);
useEffect(() => {
setVisibleTableLimit(TABLE_OVERVIEW_RENDER_BATCH_SIZE);
}, [deferredSearchText, sortField, sortOrder, viewMode, tables]);
const visibleOverview = useMemo(() => (
resolveTableOverviewVisibleRows(sortedFiltered, visibleTableLimit)
), [sortedFiltered, visibleTableLimit]);
const visibleTables = visibleOverview.visibleRows;
const openTable = useCallback((tableName: string) => {
if (!connection) return;
@@ -324,6 +364,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
title: '重命名表',
content: (
<Input
{...noAutoCapInputProps}
defaultValue={tableName}
onChange={e => { newName = e.target.value; }}
placeholder="输入新表名"
@@ -371,11 +412,11 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
{ key: 'dataSize', label: `按大小${sortField === 'dataSize' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('dataSize') },
];
const totalRows = tables.reduce((s, t) => s + t.rows, 0);
const totalSize = tables.reduce((s, t) => s + t.dataSize + t.indexSize, 0);
const maxCombinedSize = sortedFiltered.reduce((max, table) => {
const totalRows = useMemo(() => tables.reduce((s, t) => s + t.rows, 0), [tables]);
const totalSize = useMemo(() => tables.reduce((s, t) => s + t.dataSize + t.indexSize, 0), [tables]);
const maxCombinedSize = useMemo(() => sortedFiltered.reduce((max, table) => {
return Math.max(max, table.dataSize + table.indexSize);
}, 0);
}, 0), [sortedFiltered]);
const allowTruncate = supportsTableTruncateAction(connection?.config?.type || '', connection?.config?.driver);
if (loading) {
@@ -397,6 +438,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
</span>
<div style={{ flex: 1 }} />
<Input
{...noAutoCapInputProps}
placeholder="搜索表名或注释..."
prefix={<SearchOutlined style={{ color: textMuted }} />}
value={searchText}
@@ -441,6 +483,31 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
{/* Content Area */}
<div style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px 16px' }}>
{sortedFiltered.length > 0 && (isSearchPending || visibleOverview.hiddenCount > 0 || deferredSearchText.trim()) && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
marginBottom: 10,
padding: '8px 10px',
borderRadius: 10,
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.025)',
color: textMuted,
fontSize: 12,
}}
>
<span>
{isSearchPending
? '正在更新筛选结果...'
: `匹配 ${sortedFiltered.length} 张表,当前渲染 ${visibleTables.length}`}
</span>
{visibleOverview.hiddenCount > 0 && (
<span> {visibleOverview.hiddenCount} </span>
)}
</div>
)}
{sortedFiltered.length === 0 ? (
<Empty description={searchText ? '无匹配结果' : '暂无表'} style={{ marginTop: 80 }} />
) : viewMode === 'card' ? (
@@ -450,7 +517,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 12,
}}>
{sortedFiltered.map(t => (
{visibleTables.map(t => (
<Dropdown
key={t.name}
trigger={['contextMenu']}
@@ -464,7 +531,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
type: 'query',
connectionId: tab.connectionId,
dbName: tab.dbName,
query: `SELECT * FROM ${t.name};`,
query: buildTableSelectQuery(metadataDialect, t.name),
});
}},
{ type: 'divider' },
@@ -529,7 +596,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
) : (
/* ========== 行视图 ========== */
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{sortedFiltered.map(t => {
{visibleTables.map(t => {
const combinedSize = t.dataSize + t.indexSize;
const sizeRatio = maxCombinedSize > 0 ? combinedSize / maxCombinedSize : 0;
const fillWidth = maxCombinedSize > 0 ? `${Math.max(10, Math.round(sizeRatio * 100))}%` : '0%';
@@ -550,7 +617,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
type: 'query',
connectionId: tab.connectionId,
dbName: tab.dbName,
query: `SELECT * FROM ${t.name};`,
query: buildTableSelectQuery(metadataDialect, t.name),
});
}},
{ type: 'divider' },
@@ -668,6 +735,16 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
})}
</div>
)}
{sortedFiltered.length > 0 && visibleOverview.hiddenCount > 0 && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0 4px' }}>
<Button
size="small"
onClick={() => setVisibleTableLimit(limit => limit + TABLE_OVERVIEW_RENDER_BATCH_SIZE)}
>
{visibleOverview.hiddenCount}
</Button>
</div>
)}
</div>
</div>
);

View File

@@ -29,9 +29,12 @@ const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
if (type === 'custom') {
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
if (driver === 'diros' || driver === 'doris') return 'mysql';
if (driver === 'oceanbase') return 'mysql';
if (driver === 'opengauss' || driver === 'open_gauss' || driver === 'open-gauss') return 'opengauss';
return driver;
}
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'oceanbase' && String(conn?.config?.oceanBaseProtocol || '').trim().toLowerCase() === 'oracle') return 'oracle';
if (type === 'mariadb' || type === 'oceanbase' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
@@ -62,6 +65,7 @@ const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
case 'kingbase':
case 'highgo':
case 'vastbase':
case 'opengauss':
return [`SELECT pg_get_triggerdef(t.oid, true) AS trigger_definition
FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
@@ -179,7 +183,8 @@ LIMIT 1`];
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase': {
case 'vastbase':
case 'opengauss': {
return row.trigger_definition || row.TRIGGER_DEFINITION || Object.values(row)[0] || '';
}
case 'sqlserver': {

View File

@@ -36,6 +36,7 @@ describe('AIChatInput notice layout', () => {
activeProvider={{ model: '', models: [] }}
dynamicModels={[]}
loadingModels={false}
sendShortcutBinding={{ combo: 'Enter', enabled: true }}
composerNotice={{
tone: 'error',
title: '模型列表加载失败',
@@ -58,4 +59,35 @@ describe('AIChatInput notice layout', () => {
expect(inputIndex).toBeGreaterThanOrEqual(0);
expect(noticeIndex).toBeLessThan(inputIndex);
});
it('renders the selected send shortcut in the composer placeholder', () => {
const markup = renderToStaticMarkup(
<AIChatInput
input=""
setInput={() => {}}
draftImages={[]}
setDraftImages={() => {}}
sending={false}
onSend={() => {}}
onStop={() => {}}
handleKeyDown={() => {}}
activeConnName=""
activeContext={null}
activeProvider={{ model: '', models: [] }}
dynamicModels={[]}
loadingModels={false}
sendShortcutBinding={{ combo: 'Meta+Enter', enabled: true }}
composerNotice={null}
onModelChange={() => {}}
onFetchModels={() => {}}
textareaRef={React.createRef<HTMLTextAreaElement>()}
darkMode={false}
textColor="#162033"
mutedColor="rgba(16,24,40,0.55)"
overlayTheme={buildOverlayWorkbenchTheme(false)}
/>
);
expect(markup).toContain('Meta+Enter 发送');
});
});

View File

@@ -2,10 +2,13 @@ import React from 'react';
import { Input, Select, AutoComplete, Tooltip, Modal, Checkbox, Spin, message, Button, Tag } from 'antd';
import { DatabaseOutlined, SendOutlined, TableOutlined, SearchOutlined, PictureOutlined, ExclamationCircleFilled } from '@ant-design/icons';
import { useStore } from '../../store';
import { DBGetTables, DBShowCreateTable, DBGetDatabases } from '../../../wailsjs/go/app/App';
import { DBGetTables, DBShowCreateTable, DBGetDatabases, DBGetColumns } from '../../../wailsjs/go/app/App';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import type { AIComposerNotice } from '../../utils/aiComposerNotice';
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
import { resolveAITableSchemaToolResult } from '../../utils/aiTableSchemaTool';
import { getAIChatSendShortcutLabel } from '../../utils/aiChatSendShortcut';
import type { ShortcutBinding } from '../../utils/shortcuts';
interface AIChatInputProps {
input: string;
@@ -21,6 +24,7 @@ interface AIChatInputProps {
activeProvider: any;
dynamicModels: string[];
loadingModels: boolean;
sendShortcutBinding: ShortcutBinding;
composerNotice?: AIComposerNotice | null;
onModelChange: (val: string) => void;
onFetchModels: () => void;
@@ -36,7 +40,7 @@ interface AIChatInputProps {
export const AIChatInput: React.FC<AIChatInputProps> = ({
input, setInput, draftImages, setDraftImages, sending, onSend, onStop, handleKeyDown,
activeConnName, activeContext, activeProvider, dynamicModels, loadingModels,
composerNotice,
sendShortcutBinding, composerNotice,
onModelChange, onFetchModels, textareaRef, darkMode, textColor, mutedColor, overlayTheme,
contextUsageChars, maxContextChars
}) => {
@@ -202,24 +206,21 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
if (activeContextItems.find(c => c.dbName === dbName && c.tableName === tableName)) {
continue;
}
const res = await DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, dbName, tableName);
let createSql = '';
if (res.success && res.data) {
if (typeof res.data === 'string') {
createSql = res.data;
} else if (Array.isArray(res.data) && res.data.length > 0) {
const row = res.data[0];
createSql = (Object.values(row).find(v => typeof v === 'string' && (v.toUpperCase().includes('CREATE TABLE') || v.toUpperCase().includes('CREATE'))) || Object.values(row)[1] || Object.values(row)[0]) as string;
}
} else {
message.error(`获取表 ${dbName}.${tableName} 结构失败: ` + (res.message || '未知错误'));
const rpcConfig = buildRpcConnectionConfig(conn.config) as any;
const schemaResult = await resolveAITableSchemaToolResult({
tableName,
fetchDDL: () => DBShowCreateTable(rpcConfig, dbName, tableName),
fetchColumns: () => DBGetColumns(rpcConfig, dbName, tableName),
});
if (!schemaResult.success) {
message.error(`获取表 ${dbName}.${tableName} 结构失败: ${schemaResult.content}`);
}
if (createSql) {
if (schemaResult.success && schemaResult.content) {
addAIContext(connectionKey, {
dbName: dbName,
tableName: tableName,
ddl: createSql
ddl: schemaResult.content
});
addedCount++;
}
@@ -381,7 +382,7 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
}
}}
onKeyDown={handleKeyDown as any}
placeholder="输入消息... (Enter 发送Shift+Enter 换行,/ 快捷命令)"
placeholder={`输入消息... (${getAIChatSendShortcutLabel(sendShortcutBinding)}Shift+Enter 换行,/ 快捷命令)`}
variant="borderless"
autoSize={{ minRows: 1, maxRows: 8 }}
style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }}

View File

@@ -1,13 +1,21 @@
import React, { useState, useEffect, useRef } from 'react';
import { Tooltip, message } from 'antd';
import { Button, Tooltip, message } from 'antd';
import { UserOutlined, RobotOutlined, EditOutlined, ReloadOutlined, DeleteOutlined, CheckOutlined, CopyOutlined, PlayCircleOutlined, ApiOutlined, LoadingOutlined, CaretRightOutlined, CaretDownOutlined } from '@ant-design/icons';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import mermaid from 'mermaid';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { AIChatMessage, AIToolCall } from '../../types';
import type { AIChatMessage, AIToolCall } from '../../types';
import { useStore } from '../../store';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import { normalizeAiMarkdown } from '../../utils/aiMarkdown';
import { extractJVMChangePlan, resolveJVMAIPlanTargetTabId } from '../../utils/jvmAiPlan';
import {
parseJVMDiagnosticPlan,
resolveJVMDiagnosticPlanTargetTabId,
} from '../../utils/jvmDiagnosticPlan';
import { buildAIReadonlyPreviewSQL } from '../../utils/aiSqlLimit';
// 🔧 性能优化:将 ReactMarkdown 包装为 Memo 组件并提取固定的 plugins
const remarkPlugins = [remarkGfm];
@@ -27,6 +35,7 @@ const MemoizedMarkdown = React.memo(({
activeConnectionId?: string;
activeDbName?: string;
}) => {
const normalizedContent = React.useMemo(() => normalizeAiMarkdown(content), [content]);
// 缓存 components 对象,避免每次渲染都生成新的函数引用击穿内部子组件的 memo
const components = React.useMemo(() => ({
code({ node, inline, className, children, ...props }: any) {
@@ -46,7 +55,7 @@ const MemoizedMarkdown = React.memo(({
return (
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
{content}
{normalizedContent}
</ReactMarkdown>
);
});
@@ -252,7 +261,13 @@ const AIBlockHashRender = ({ match, darkMode, overlayTheme, children, activeConn
setPreviewData(null);
try {
const { DBQuery } = await import('../../../wailsjs/go/app/App');
const res = await DBQuery(activeConnectionConfig, activeDbName || '', displayText + ' LIMIT 50');
const previewSql = buildAIReadonlyPreviewSQL(
activeConnectionConfig?.type || '',
displayText,
50,
activeConnectionConfig?.driver || '',
);
const res = await DBQuery(activeConnectionConfig, activeDbName || '', previewSql);
if (res.success && Array.isArray(res.data)) {
const rows = res.data as any[];
const cols = rows.length > 0 ? Object.keys(rows[0]) : [];
@@ -566,6 +581,18 @@ export const AIMessageBubble: React.FC<AIMessageBubbleProps> = React.memo(({ msg
}
return { displayContent: content, parsedThinking: '' };
}, [msg.content, msg.thinking]);
const jvmPlan = React.useMemo(() => {
if (isUser) {
return null;
}
return extractJVMChangePlan(displayContent);
}, [displayContent, isUser]);
const jvmDiagnosticPlan = React.useMemo(() => {
if (isUser) {
return null;
}
return parseJVMDiagnosticPlan(displayContent);
}, [displayContent, isUser]);
const isTypingThinking = !!(msg.loading && msg.phase === 'thinking');
if (msg.role === 'tool') return null;
@@ -693,6 +720,77 @@ export const AIMessageBubble: React.FC<AIMessageBubbleProps> = React.memo(({ msg
activeDbName={activeDbName}
/>
)}
{!isUser && jvmPlan && (
<div style={{ marginTop: 12 }}>
<Button
size="small"
type="primary"
onClick={() => {
const targetContext = msg.jvmPlanContext;
if (!targetContext) {
message.warning('这条 JVM 计划缺少来源页签上下文,请在目标 JVM 资源页重新生成。');
return;
}
const store = useStore.getState();
const targetTabId = resolveJVMAIPlanTargetTabId(store.tabs, targetContext);
if (!targetTabId) {
message.warning('未找到与该 JVM 计划匹配的资源页签,请先打开原目标资源后再应用。');
return;
}
window.dispatchEvent(new CustomEvent('gonavi:jvm-apply-ai-plan', {
detail: {
plan: jvmPlan,
targetTabId,
connectionId: targetContext.connectionId,
providerMode: targetContext.providerMode,
resourcePath: targetContext.resourcePath,
},
}));
}}
>
JVM
</Button>
</div>
)}
{!isUser && jvmDiagnosticPlan && (
<div style={{ marginTop: 12 }}>
<Button
size="small"
type="primary"
onClick={() => {
const targetContext = msg.jvmDiagnosticPlanContext;
if (!targetContext) {
message.warning('这条诊断计划缺少来源页签上下文,请在目标诊断控制台重新生成。');
return;
}
const store = useStore.getState();
const targetTabId = resolveJVMDiagnosticPlanTargetTabId(
store.tabs,
store.connections,
targetContext,
);
if (!targetTabId) {
message.warning('未找到与该诊断计划匹配的诊断控制台页签,请先打开原目标控制台后再应用。');
return;
}
window.dispatchEvent(new CustomEvent('gonavi:jvm-apply-diagnostic-plan', {
detail: {
plan: jvmDiagnosticPlan,
targetTabId,
connectionId: targetContext.connectionId,
transport: targetContext.transport,
},
}));
}}
>
</Button>
</div>
)}
{/* 错误原文复制按钮 */}
{!isUser && msg.rawError && (
<div style={{ marginTop: 8 }}>

View File

@@ -141,6 +141,33 @@ describe('buildCopyInsertSQL', () => {
});
});
it('uses Oracle date constructors when all-column DELETE matching includes DATE values', () => {
const result = buildCopyDeleteSQL({
dbType: 'oracle',
tableName: 'LZJ.RIJIE_TABLE',
orderedCols: ['NAME', 'CREATED_AT', 'STATUS', 'MEMO'],
allTableColumns: ['NAME', 'CREATED_AT', 'STATUS', 'MEMO'],
record: {
NAME: '张三',
CREATED_AT: '2026-04-26T08:30:00+08:00',
STATUS: 'DONE',
MEMO: null,
},
columnTypesByLowerName: {
name: 'NVARCHAR2',
created_at: 'DATE',
status: 'VARCHAR2',
memo: 'VARCHAR2',
},
});
expect(result).toEqual({
ok: true,
whereStrategy: 'all-columns',
sql: `DELETE FROM "LZJ"."RIJIE_TABLE" WHERE ("NAME" = '张三' AND "CREATED_AT" = TO_DATE('2026-04-26 08:30:00', 'YYYY-MM-DD HH24:MI:SS') AND "STATUS" = 'DONE' AND "MEMO" IS NULL);`,
});
});
it('refuses to build UPDATE/DELETE SQL when the result set lacks keys and does not cover all table columns', () => {
const result = buildCopyDeleteSQL({
dbType: 'mysql',

View File

@@ -1,5 +1,6 @@
import type { IndexDefinition } from '../types';
import { escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
import { isOracleLikeDialect } from '../utils/sqlDialect';
type BuildCopyInsertSQLParams = {
dbType: string;
@@ -164,10 +165,36 @@ const toNormalizedLiteralText = (value: any, columnType?: string): string => {
return String(value);
};
const formatCopySqlLiteral = (value: any, columnType?: string): string => {
const formatOracleTemporalLiteral = (value: any, columnType?: string): string | null => {
if (!isTemporalColumnType(columnType)) {
return null;
}
const normalized = toNormalizedLiteralText(value, columnType);
const escaped = escapeLiteral(normalized);
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
return `TO_DATE('${escaped}', 'YYYY-MM-DD')`;
}
if (isTimezoneAwareColumnType(columnType) && /[+-]\d{2}:?\d{2}$/.test(normalized)) {
const compactOffset = normalized.replace(/([+-]\d{2}):(\d{2})$/, '$1:$2');
return `TO_TIMESTAMP_TZ('${escapeLiteral(compactOffset)}', 'YYYY-MM-DD HH24:MI:SSTZH:TZM')`;
}
const rawType = String(columnType || '').toLowerCase();
if (rawType.includes('timestamp')) {
return `TO_TIMESTAMP('${escaped}', 'YYYY-MM-DD HH24:MI:SS')`;
}
return `TO_DATE('${escaped}', 'YYYY-MM-DD HH24:MI:SS')`;
};
const formatCopySqlLiteral = (value: any, columnType?: string, dbType = ''): string => {
if (value === null || value === undefined) {
return 'NULL';
}
if (isOracleLikeDialect(dbType)) {
const oracleTemporalLiteral = formatOracleTemporalLiteral(value, columnType);
if (oracleTemporalLiteral) {
return oracleTemporalLiteral;
}
}
return `'${escapeLiteral(toNormalizedLiteralText(value, columnType))}'`;
};
@@ -208,7 +235,7 @@ const buildWhereClauseForColumns = ({
predicates.push(`${quotedColumn} IS NULL`);
continue;
}
predicates.push(`${quotedColumn} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName))}`);
predicates.push(`${quotedColumn} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName), dbType)}`);
}
if (predicates.length === 0) {
return null;
@@ -283,7 +310,7 @@ export const buildCopyInsertSQL = ({
const quotedCols = orderedCols.map((col) => quoteIdentPart(dbType, col));
const values = orderedCols.map((col) => {
const { value } = getRecordValue(record, col);
return formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, col));
return formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, col), dbType);
});
return `INSERT INTO ${targetTable} (${quotedCols.join(', ')}) VALUES (${values.join(', ')});`;
@@ -341,7 +368,7 @@ const buildCopyMutationSQL = (
const assignments = normalizedOrderedCols.map((columnName) => {
const { value } = getRecordValue(record, columnName);
return `${quoteIdentPart(dbType, columnName)} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName))}`;
return `${quoteIdentPart(dbType, columnName)} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName), dbType)}`;
});
return {

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { buildCopiedRowsForPaste, buildPastedRowsFromCopiedRows } from './dataGridRowClipboard';
const rowKeyField = '__gonavi_row_key__';
describe('dataGridRowClipboard', () => {
it('copies selected rows in selection order without the internal row key', () => {
const copiedRows = buildCopiedRowsForPaste({
rows: [
{ [rowKeyField]: 'row-1', id: 1, name: 'alpha', hidden_note: 'A' },
{ [rowKeyField]: 'row-2', id: 2, name: 'beta', hidden_note: 'B' },
],
selectedRowKeys: ['row-2', 'row-1'],
columnNames: ['id', 'name', 'hidden_note'],
rowKeyField,
});
expect(copiedRows).toEqual([
{ id: 2, name: 'beta', hidden_note: 'B' },
{ id: 1, name: 'alpha', hidden_note: 'A' },
]);
});
it('builds pasted rows as new rows with fresh internal keys', () => {
const pastedRows = buildPastedRowsFromCopiedRows({
rows: [
{ id: 2, name: 'beta' },
{ id: 1, name: 'alpha' },
],
columnNames: ['id', 'name'],
rowKeyField,
createRowKey: (index) => `paste-${index}`,
});
expect(pastedRows).toEqual([
{ [rowKeyField]: 'paste-0', id: 2, name: 'beta' },
{ [rowKeyField]: 'paste-1', id: 1, name: 'alpha' },
]);
});
});

View File

@@ -0,0 +1,66 @@
export interface BuildCopiedRowsForPasteInput {
rows: Array<Record<string, any>>;
selectedRowKeys: any[];
columnNames: string[];
rowKeyField: string;
rowKeyToString?: (key: any) => string;
}
export interface BuildPastedRowsFromCopiedRowsInput {
rows: Array<Record<string, any>>;
columnNames: string[];
rowKeyField: string;
createRowKey: (index: number) => string;
}
const defaultRowKeyToString = (key: any): string => String(key);
const getCopyableColumnNames = (columnNames: string[], rowKeyField: string): string[] =>
columnNames.filter((columnName) => columnName !== rowKeyField);
const pickCopyableRowValues = (
row: Record<string, any>,
columnNames: string[],
rowKeyField: string,
): Record<string, any> => {
const next: Record<string, any> = {};
getCopyableColumnNames(columnNames, rowKeyField).forEach((columnName) => {
next[columnName] = row?.[columnName];
});
return next;
};
export const buildCopiedRowsForPaste = ({
rows,
selectedRowKeys,
columnNames,
rowKeyField,
rowKeyToString = defaultRowKeyToString,
}: BuildCopiedRowsForPasteInput): Array<Record<string, any>> => {
if (!Array.isArray(rows) || !Array.isArray(selectedRowKeys) || selectedRowKeys.length === 0) {
return [];
}
const rowsByKey = new Map<string, Record<string, any>>();
rows.forEach((row) => {
const rowKey = row?.[rowKeyField];
if (rowKey === undefined || rowKey === null) return;
rowsByKey.set(rowKeyToString(rowKey), row);
});
return selectedRowKeys
.map((selectedKey) => rowsByKey.get(rowKeyToString(selectedKey)))
.filter((row): row is Record<string, any> => Boolean(row))
.map((row) => pickCopyableRowValues(row, columnNames, rowKeyField));
};
export const buildPastedRowsFromCopiedRows = ({
rows,
columnNames,
rowKeyField,
createRowKey,
}: BuildPastedRowsFromCopiedRowsInput): Array<Record<string, any>> =>
rows.map((row, index) => ({
[rowKeyField]: createRowKey(index),
...pickCopyableRowValues(row, columnNames, rowKeyField),
}));

View File

@@ -0,0 +1,10 @@
import dayjs from 'dayjs';
import { describe, expect, it } from 'vitest';
import { resolveTemporalEditorSaveValue } from './dataGridTemporal';
describe('dataGridTemporal helpers', () => {
it('prefers the picker selected date when form store has not caught up yet', () => {
expect(resolveTemporalEditorSaveValue(undefined, dayjs('2026-04-12'), 'date')).toBe('2026-04-12');
});
});

View File

@@ -0,0 +1,59 @@
import dayjs from 'dayjs';
export type TemporalPickerType = 'datetime' | 'date' | 'time' | 'year' | null;
export const TEMPORAL_FORMATS: Record<string, string> = {
datetime: 'YYYY-MM-DD HH:mm:ss',
date: 'YYYY-MM-DD',
time: 'HH:mm:ss',
year: 'YYYY',
};
export const isTemporalColumnType = (columnType?: string): boolean => {
const raw = String(columnType || '').trim().toLowerCase();
if (!raw) return false;
if (raw.includes('datetime') || raw.includes('timestamp')) return true;
const base = raw.split(/[ (]/)[0];
return base === 'date' || base === 'time' || base === 'year';
};
export const getTemporalPickerType = (columnType?: string): TemporalPickerType => {
const raw = String(columnType || '').trim().toLowerCase();
if (!raw) return null;
if (raw.includes('datetime') || raw.includes('timestamp')) return 'datetime';
const base = raw.split(/[ (]/)[0];
if (base === 'date') return 'date';
if (base === 'time') return 'time';
if (base === 'year') return 'year';
return null;
};
export const parseToDayjs = (val: any, pickerType: TemporalPickerType): dayjs.Dayjs | null => {
if (val === null || val === undefined || val === '') return null;
const str = String(val).trim();
if (!str || /^0{4}-0{2}-0{2}/.test(str)) return null;
const fmt = TEMPORAL_FORMATS[pickerType || 'datetime'];
const d = dayjs(str, fmt);
return d.isValid() ? d : dayjs(str).isValid() ? dayjs(str) : null;
};
export const formatFromDayjs = (val: dayjs.Dayjs | null, pickerType: TemporalPickerType): string => {
if (!val || !val.isValid()) return '';
const fmt = TEMPORAL_FORMATS[pickerType || 'datetime'];
return val.format(fmt);
};
export const resolveTemporalEditorSaveValue = (
formValue: any,
pickerValue: dayjs.Dayjs | null | undefined,
pickerType: TemporalPickerType,
): string | null | any => {
const value = pickerValue !== undefined ? pickerValue : formValue;
if (value && dayjs.isDayjs(value)) {
return formatFromDayjs(value as dayjs.Dayjs, pickerType);
}
if (!value) {
return null;
}
return value;
};

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { buildDataSyncRequest, validateDataSyncSelection } from './dataSyncRequest';
describe('validateDataSyncSelection', () => {
it('requires source query and single target table in query mode', () => {
expect(validateDataSyncSelection({
sourceDatasetMode: 'query',
selectedTables: [],
sourceQuery: '',
syncContent: 'data',
})).toBe('请输入源查询 SQL');
expect(validateDataSyncSelection({
sourceDatasetMode: 'query',
selectedTables: [],
sourceQuery: 'select 1',
syncContent: 'data',
})).toBe('SQL 结果集同步需要选择一个目标表');
expect(validateDataSyncSelection({
sourceDatasetMode: 'query',
selectedTables: ['users', 'orders'],
sourceQuery: 'select 1',
syncContent: 'data',
})).toBe('SQL 结果集同步需要选择一个目标表');
});
it('forces data-only in query mode', () => {
expect(validateDataSyncSelection({
sourceDatasetMode: 'query',
selectedTables: ['users'],
sourceQuery: 'select 1',
syncContent: 'both',
})).toBe('SQL 结果集同步仅支持仅同步数据');
});
});
describe('buildDataSyncRequest', () => {
it('normalizes query mode payload for backend', () => {
const payload = buildDataSyncRequest({
sourceConfig: { type: 'mysql' },
targetConfig: { type: 'mysql' },
selectedTables: ['users'],
sourceDatasetMode: 'query',
sourceQuery: ' SELECT id, name FROM active_users ',
syncContent: 'both',
syncMode: 'insert_update',
autoAddColumns: true,
targetTableStrategy: 'smart',
createIndexes: true,
mongoCollectionName: ' ',
jobId: 'job-1',
tableOptions: { users: { insert: true, update: true, delete: false } },
});
expect(payload).toMatchObject({
tables: ['users'],
sourceQuery: 'SELECT id, name FROM active_users',
content: 'data',
mode: 'insert_update',
autoAddColumns: false,
targetTableStrategy: 'existing_only',
createIndexes: false,
jobId: 'job-1',
});
});
});

View File

@@ -0,0 +1,85 @@
export type SourceDatasetMode = 'table' | 'query';
type SyncContent = 'data' | 'schema' | 'both';
type TargetTableStrategy = 'existing_only' | 'auto_create_if_missing' | 'smart';
type BuildDataSyncRequestParams = {
sourceConfig: any;
targetConfig: any;
selectedTables: string[];
sourceDatasetMode: SourceDatasetMode;
sourceQuery: string;
syncContent: SyncContent;
syncMode: string;
autoAddColumns: boolean;
targetTableStrategy: TargetTableStrategy;
createIndexes: boolean;
mongoCollectionName: string;
jobId?: string;
tableOptions?: Record<string, any>;
};
type ValidateDataSyncSelectionParams = {
sourceDatasetMode: SourceDatasetMode;
selectedTables: string[];
sourceQuery: string;
syncContent: SyncContent;
};
export const validateDataSyncSelection = ({
sourceDatasetMode,
selectedTables,
sourceQuery,
syncContent,
}: ValidateDataSyncSelectionParams): string | null => {
if (sourceDatasetMode === 'query') {
if (!String(sourceQuery || '').trim()) {
return '请输入源查询 SQL';
}
if (selectedTables.length !== 1) {
return 'SQL 结果集同步需要选择一个目标表';
}
if (syncContent !== 'data') {
return 'SQL 结果集同步仅支持仅同步数据';
}
return null;
}
if (selectedTables.length === 0) {
return '请选择至少一张表';
}
return null;
};
export const buildDataSyncRequest = ({
sourceConfig,
targetConfig,
selectedTables,
sourceDatasetMode,
sourceQuery,
syncContent,
syncMode,
autoAddColumns,
targetTableStrategy,
createIndexes,
mongoCollectionName,
jobId,
tableOptions,
}: BuildDataSyncRequestParams) => {
const isQueryMode = sourceDatasetMode === 'query';
return {
sourceConfig,
targetConfig,
tables: selectedTables,
sourceQuery: isQueryMode ? String(sourceQuery || '').trim() : undefined,
content: isQueryMode ? 'data' : syncContent,
mode: syncMode,
autoAddColumns: isQueryMode ? false : autoAddColumns,
targetTableStrategy: isQueryMode ? 'existing_only' : targetTableStrategy,
createIndexes: isQueryMode ? false : createIndexes,
mongoCollectionName: String(mongoCollectionName || '').trim(),
...(jobId ? { jobId } : {}),
...(tableOptions ? { tableOptions } : {}),
};
};

View File

@@ -0,0 +1,164 @@
import React, { useMemo } from "react";
import { Alert, Descriptions, Modal, Space, Tag, Typography } from "antd";
import type { JVMChangePreview } from "../../types";
import {
formatJVMRiskLevelText,
formatJVMValueForDisplay,
} from "../../utils/jvmResourcePresentation";
const { Text } = Typography;
const DESCRIPTION_STYLES = { label: { width: 120 } } as const;
type JVMChangePreviewModalProps = {
open: boolean;
preview: JVMChangePreview | null;
applying?: boolean;
onCancel: () => void;
onConfirm: () => void;
};
const riskColorMap: Record<string, string> = {
low: "green",
medium: "orange",
high: "red",
};
const previewBlockStyle: React.CSSProperties = {
margin: 0,
padding: 12,
borderRadius: 8,
background: "rgba(0, 0, 0, 0.04)",
overflow: "auto",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
maxHeight: 280,
};
const JVMChangePreviewModal: React.FC<JVMChangePreviewModalProps> = ({
open,
preview,
applying = false,
onCancel,
onConfirm,
}) => {
const summary = useMemo(() => {
if (!preview) {
return "暂无预览结果";
}
return preview.summary || "预览已生成";
}, [preview]);
return (
<Modal
title="JVM 变更预览"
open={open}
onCancel={onCancel}
onOk={onConfirm}
okText="确认执行"
cancelText="关闭"
okButtonProps={{ disabled: !preview?.allowed, loading: applying }}
width={880}
destroyOnClose
>
{!preview ? (
<Alert type="info" showIcon message="暂无预览结果" />
) : (
<Space direction="vertical" size={16} style={{ width: "100%" }}>
<Descriptions column={1} size="small" styles={DESCRIPTION_STYLES}>
<Descriptions.Item label="变更摘要">
<Space size={8} wrap>
<Text>{summary}</Text>
<Tag color={riskColorMap[preview.riskLevel] || "default"}>
{formatJVMRiskLevelText(preview.riskLevel)}
</Tag>
{preview.requiresConfirmation ? (
<Tag color="gold"></Tag>
) : null}
{preview.allowed ? (
<Tag color="green"></Tag>
) : (
<Tag color="red"></Tag>
)}
</Space>
</Descriptions.Item>
{preview.blockingReason ? (
<Descriptions.Item label="阻断原因">
<Text type="danger" style={{ whiteSpace: "pre-wrap" }}>
{preview.blockingReason}
</Text>
</Descriptions.Item>
) : null}
</Descriptions>
{!preview.allowed && preview.blockingReason ? (
<Alert
type="error"
showIcon
message="当前变更不可执行"
description={
<span style={{ whiteSpace: "pre-wrap" }}>
{preview.blockingReason}
</span>
}
/>
) : (
<Alert type="info" showIcon message={summary} />
)}
<div>
<Text strong style={{ display: "block", marginBottom: 8 }}>
</Text>
<Descriptions
column={1}
size="small"
styles={DESCRIPTION_STYLES}
style={{ marginBottom: 12 }}
>
<Descriptions.Item label="资源 ID">
{preview.before?.resourceId || "-"}
</Descriptions.Item>
<Descriptions.Item label="版本">
{preview.before?.version || "-"}
</Descriptions.Item>
<Descriptions.Item label="格式">
{preview.before?.format || "-"}
</Descriptions.Item>
</Descriptions>
<pre style={previewBlockStyle}>
{formatJVMValueForDisplay(preview.before)}
</pre>
</div>
<div>
<Text strong style={{ display: "block", marginBottom: 8 }}>
</Text>
<Descriptions
column={1}
size="small"
styles={DESCRIPTION_STYLES}
style={{ marginBottom: 12 }}
>
<Descriptions.Item label="资源 ID">
{preview.after?.resourceId || "-"}
</Descriptions.Item>
<Descriptions.Item label="版本">
{preview.after?.version || "-"}
</Descriptions.Item>
<Descriptions.Item label="格式">
{preview.after?.format || "-"}
</Descriptions.Item>
</Descriptions>
<pre style={previewBlockStyle}>
{formatJVMValueForDisplay(preview.after)}
</pre>
</div>
</Space>
)}
</Modal>
);
};
export default JVMChangePreviewModal;

View File

@@ -0,0 +1,67 @@
import React from "react";
import { Button, Card, Space, Tag, Typography } from "antd";
import {
formatJVMDiagnosticRiskLabel,
groupJVMDiagnosticPresets,
resolveJVMDiagnosticRiskColor,
type JVMDiagnosticCommandPreset,
} from "../../utils/jvmDiagnosticPresentation";
const { Text } = Typography;
type JVMCommandPresetBarProps = {
onSelectPreset: (preset: JVMDiagnosticCommandPreset) => void;
};
const JVMCommandPresetBar: React.FC<JVMCommandPresetBarProps> = ({
onSelectPreset,
}) => (
<div style={{ display: "grid", gap: 12 }}>
{groupJVMDiagnosticPresets().map((group) => (
<Card
key={group.category}
size="small"
title={group.label}
style={{ borderRadius: 14 }}
styles={{
header: { minHeight: 38, paddingInline: 12 },
body: { display: "grid", gap: 8, padding: 12 },
}}
>
{group.items.map((preset) => (
<div
key={preset.key}
style={{
display: "grid",
gap: 6,
padding: 10,
borderRadius: 12,
background: "rgba(127,127,127,0.06)",
}}
>
<Space size={8} wrap>
<Button
size="small"
type="text"
onClick={() => onSelectPreset(preset)}
style={{ paddingInline: 8, fontWeight: 700 }}
>
{preset.label}
</Button>
<Tag color={resolveJVMDiagnosticRiskColor(preset.riskLevel)}>
{formatJVMDiagnosticRiskLabel(preset.riskLevel)}
</Tag>
</Space>
<Text type="secondary">{preset.description}</Text>
<Text code style={{ width: "fit-content" }}>
{preset.command}
</Text>
</div>
))}
</Card>
))}
</div>
);
export default JVMCommandPresetBar;

View File

@@ -0,0 +1,97 @@
import React from "react";
import { Empty, List, Tag, Typography } from "antd";
import type {
JVMDiagnosticAuditRecord,
JVMDiagnosticSessionHandle,
} from "../../types";
import {
formatJVMDiagnosticCommandTypeLabel,
formatJVMDiagnosticRiskLabel,
formatJVMDiagnosticSourceLabel,
formatJVMDiagnosticPhaseLabel,
formatJVMDiagnosticTransportLabel,
} from "../../utils/jvmDiagnosticPresentation";
const { Text } = Typography;
type JVMDiagnosticHistoryProps = {
session?: JVMDiagnosticSessionHandle | null;
records?: JVMDiagnosticAuditRecord[];
showSession?: boolean;
maxHeight?: number;
};
const JVMDiagnosticHistory: React.FC<JVMDiagnosticHistoryProps> = ({
session,
records = [],
showSession = true,
maxHeight = 360,
}) => (
<div style={{ display: "grid", gap: 12 }}>
{showSession ? (
<div style={{ display: "grid", gap: 4 }}>
<Text strong></Text>
{session ? (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Tag color="blue">{session.sessionId}</Tag>
<Tag>{formatJVMDiagnosticTransportLabel(session.transport)}</Tag>
</div>
) : (
<Empty
description="尚未建立诊断会话"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</div>
) : null}
<div style={{ display: "grid", gap: 8 }}>
<Text strong></Text>
{records.length ? (
<div style={{ maxHeight, overflow: "auto", paddingRight: 4 }}>
<List
size="small"
dataSource={records}
renderItem={(record) => (
<List.Item
key={`${record.sessionId || "record"}-${record.commandId || record.command}-${record.timestamp}`}
>
<div style={{ display: "grid", gap: 4, width: "100%" }}>
<Text
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontFamily: "SFMono-Regular, Menlo, Monaco, Consolas, monospace",
}}
>
{record.command}
</Text>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{record.status ? (
<Tag color="green">{formatJVMDiagnosticPhaseLabel(record.status)}</Tag>
) : null}
{record.riskLevel ? (
<Tag color="gold">{formatJVMDiagnosticRiskLabel(record.riskLevel)}</Tag>
) : null}
{record.commandType ? (
<Tag color="blue">{formatJVMDiagnosticCommandTypeLabel(record.commandType)}</Tag>
) : null}
{record.source ? <Tag>{formatJVMDiagnosticSourceLabel(record.source)}</Tag> : null}
</div>
<Text type="secondary">
{record.reason || "未填写诊断原因"}
</Text>
</div>
</List.Item>
)}
/>
</div>
) : (
<Empty description="尚无诊断历史" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</div>
</div>
);
export default JVMDiagnosticHistory;

View File

@@ -0,0 +1,67 @@
import React from "react";
import { Empty, List, Tag, Typography } from "antd";
import type { JVMDiagnosticEventChunk } from "../../types";
import {
formatJVMDiagnosticChunksForDisplay,
formatJVMDiagnosticEventLabel,
formatJVMDiagnosticPhaseLabel,
} from "../../utils/jvmDiagnosticPresentation";
const { Text } = Typography;
type JVMDiagnosticOutputProps = {
chunks: JVMDiagnosticEventChunk[];
maxHeight?: number;
};
const JVMDiagnosticOutput: React.FC<JVMDiagnosticOutputProps> = ({
chunks,
maxHeight = 420,
}) => {
if (!chunks.length) {
return (
<Empty
description="暂无实时输出。命令执行后,这里会按时间顺序追加后端返回内容。"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
);
}
const chunkTexts = formatJVMDiagnosticChunksForDisplay(chunks);
return (
<div style={{ maxHeight, overflow: "auto", paddingRight: 4 }}>
<List
size="small"
dataSource={chunks}
renderItem={(chunk, index) => (
<List.Item
key={`${chunk.sessionId}-${chunk.commandId || "chunk"}-${index}`}
>
<div style={{ display: "grid", gap: 4, width: "100%" }}>
<Text
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontFamily: "SFMono-Regular, Menlo, Monaco, Consolas, monospace",
}}
>
{chunkTexts[index]}
</Text>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{chunk.phase ? (
<Tag color="geekblue">{formatJVMDiagnosticPhaseLabel(chunk.phase)}</Tag>
) : null}
{chunk.event ? <Tag>{formatJVMDiagnosticEventLabel(chunk.event)}</Tag> : null}
{chunk.commandId ? <Tag color="blue">{chunk.commandId}</Tag> : null}
</div>
</div>
</List.Item>
)}
/>
</div>
);
};
export default JVMDiagnosticOutput;

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { Tooltip } from 'antd';
import { resolveJVMModeMeta } from '../../utils/jvmRuntimePresentation';
type JVMModeBadgeProps = {
mode: string;
label?: string;
reason?: string;
};
const JVMModeBadge: React.FC<JVMModeBadgeProps> = ({
mode,
label,
reason,
}) => {
const meta = resolveJVMModeMeta(mode);
const displayLabel = String(label || meta.label || 'Unknown').trim() || 'Unknown';
const content = (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
minWidth: 0,
}}
>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
height: 20,
padding: '0 8px',
borderRadius: 999,
fontSize: 12,
fontWeight: 600,
color: meta.color,
background: meta.backgroundColor,
flexShrink: 0,
}}
>
{displayLabel}
</span>
{reason ? (
<span
style={{
fontSize: 12,
color: '#cf1322',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{reason}
</span>
) : null}
</span>
);
if (!reason) {
return content;
}
return <Tooltip title={reason}>{content}</Tooltip>;
};
export default JVMModeBadge;

View File

@@ -0,0 +1,119 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import JVMMonitoringCharts from "./JVMMonitoringCharts";
vi.mock("recharts", () => {
const passthrough =
(tag: string) =>
({ children, name }: { children?: React.ReactNode; name?: string }) =>
React.createElement(tag, null, name ? <span>{name}</span> : children);
const svgChild =
({ name }: { name?: string }) =>
name ? <text>{name}</text> : <g />;
return {
Area: svgChild,
AreaChart: passthrough("svg"),
CartesianGrid: svgChild,
Legend: svgChild,
Line: svgChild,
LineChart: passthrough("svg"),
ResponsiveContainer: passthrough("div"),
Tooltip: svgChild,
XAxis: svgChild,
YAxis: svgChild,
};
});
describe("JVMMonitoringCharts", () => {
it("renders chart titles, empty text, and legends in Chinese", () => {
const emptyMarkup = renderToStaticMarkup(
<JVMMonitoringCharts
darkMode={false}
session={{
connectionId: "conn-1",
providerMode: "jmx",
running: false,
availableMetrics: [],
missingMetrics: [],
providerWarnings: [],
}}
points={[]}
/>,
);
expect(emptyMarkup).toContain("堆内存");
expect(emptyMarkup).toContain("暂无堆内存采样数据");
expect(emptyMarkup).not.toContain("暂无 Heap 采样数据");
const dataMarkup = renderToStaticMarkup(
<JVMMonitoringCharts
darkMode={false}
session={{
connectionId: "conn-1",
providerMode: "jmx",
running: true,
availableMetrics: [
"heap.used",
"gc.count",
"thread.count",
"class.loading",
],
missingMetrics: [],
providerWarnings: [],
}}
points={[
{
timestamp: 1713945600000,
heapUsedBytes: 64 * 1024 * 1024,
heapCommittedBytes: 128 * 1024 * 1024,
gcCollectionCount: 20,
gcCollectionTimeMs: 50,
threadCount: 33,
daemonThreadCount: 12,
peakThreadCount: 44,
loadedClassCount: 13282,
unloadedClassCount: 3,
},
]}
/>,
);
expect(dataMarkup).toContain("堆内存已使用");
expect(dataMarkup).toContain("堆内存已提交");
expect(dataMarkup).toContain("垃圾回收次数");
expect(dataMarkup).toContain("垃圾回收耗时(ms)");
expect(dataMarkup).toContain("线程数");
expect(dataMarkup).toContain("守护线程数");
expect(dataMarkup).toContain("线程峰值");
expect(dataMarkup).toContain("已加载类");
expect(dataMarkup).toContain("已卸载类");
expect(dataMarkup).not.toContain("Heap Used");
expect(dataMarkup).not.toContain("GC Count");
expect(dataMarkup).not.toContain("Threads");
expect(dataMarkup).not.toContain("ClassLoading");
});
it("uses relaxed card spacing so charts do not feel crowded", () => {
const markup = renderToStaticMarkup(
<JVMMonitoringCharts
darkMode={false}
session={{
connectionId: "conn-1",
providerMode: "jmx",
running: false,
availableMetrics: [],
missingMetrics: [],
providerWarnings: [],
}}
points={[]}
/>,
);
expect(markup).toContain("row-gap:24px");
expect(markup).toContain("height:380px");
expect(markup).toContain("padding:20px 22px 14px");
});
});

View File

@@ -0,0 +1,185 @@
import React from "react";
import { Card, Col, Empty, Row } from "antd";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip as RechartsTooltip,
XAxis,
YAxis,
} from "recharts";
import type { JVMMonitoringPoint, JVMMonitoringSessionState } from "../../types";
import {
buildMonitoringChartPoints,
formatCompactNumber,
formatMonitoringAxisBytes,
monitoringMetricAvailable,
} from "../../utils/jvmMonitoringPresentation";
type JVMMonitoringChartsProps = {
points: JVMMonitoringPoint[];
session: JVMMonitoringSessionState;
darkMode: boolean;
};
const buildCardStyle = (darkMode: boolean): React.CSSProperties => ({
borderRadius: 18,
height: 380,
background: darkMode ? "#1f1f1f" : "#ffffff",
boxShadow: "0 8px 28px rgba(15, 23, 42, 0.06)",
});
const chartMargin = { top: 18, right: 28, bottom: 26, left: 8 };
const axisTickStyle = (color: string) => ({ fill: color, fontSize: 11 });
const legendProps = {
iconSize: 8,
verticalAlign: "bottom" as const,
wrapperStyle: {
paddingTop: 14,
lineHeight: "22px",
},
};
const JVMMonitoringCharts: React.FC<JVMMonitoringChartsProps> = ({
points,
session,
darkMode,
}) => {
const data = buildMonitoringChartPoints(points);
const textColor = darkMode ? "rgba(255,255,255,0.72)" : "rgba(0,0,0,0.65)";
const gridColor = darkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)";
const tooltipStyle = {
backgroundColor: darkMode ? "#141414" : "#ffffff",
border: `1px solid ${gridColor}`,
borderRadius: 8,
};
const renderEmpty = (description: string) => (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={description}
style={{ marginTop: 96 }}
/>
);
const renderCard = (title: string, content: React.ReactNode) => (
<Card
variant="borderless"
title={title}
style={buildCardStyle(darkMode)}
styles={{ body: { height: 304, padding: "20px 22px 14px" } }}
>
{content}
</Card>
);
const hasData = data.length > 0;
return (
<Row gutter={[24, 24]}>
<Col xs={24} xl={12}>
{renderCard(
"堆内存",
!hasData
? renderEmpty("暂无堆内存采样数据")
: !monitoringMetricAvailable(session, "heap.used")
? renderEmpty("当前监控来源未提供堆内存指标")
: (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={chartMargin}>
<defs>
<linearGradient id="jvmHeapGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#fa8c16" stopOpacity={0.28} />
<stop offset="95%" stopColor="#fa8c16" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} vertical={false} />
<XAxis dataKey="timeLabel" tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} minTickGap={32} />
<YAxis tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} tickFormatter={formatMonitoringAxisBytes} width={74} />
<RechartsTooltip contentStyle={tooltipStyle} />
<Legend {...legendProps} />
<Area type="monotone" dataKey="heapUsedBytes" name="堆内存已使用" stroke="#fa8c16" fill="url(#jvmHeapGradient)" isAnimationActive={false} />
<Line type="monotone" dataKey="heapCommittedBytes" name="堆内存已提交" stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
),
)}
</Col>
<Col xs={24} xl={12}>
{renderCard(
"垃圾回收",
!hasData
? renderEmpty("暂无垃圾回收采样数据")
: !monitoringMetricAvailable(session, "gc.count")
? renderEmpty("当前监控来源未提供垃圾回收指标")
: (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={chartMargin}>
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} vertical={false} />
<XAxis dataKey="timeLabel" tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} minTickGap={32} />
<YAxis yAxisId="left" tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} width={42} />
<YAxis yAxisId="right" orientation="right" tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} width={42} />
<RechartsTooltip contentStyle={tooltipStyle} />
<Legend {...legendProps} />
<Line yAxisId="left" type="monotone" dataKey="gcCollectionCount" name="垃圾回收次数" stroke="#52c41a" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line yAxisId="right" type="monotone" dataKey="gcCollectionTimeMs" name="垃圾回收耗时(ms)" stroke="#722ed1" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
),
)}
</Col>
<Col xs={24} xl={12}>
{renderCard(
"线程",
!hasData
? renderEmpty("暂无线程采样数据")
: !monitoringMetricAvailable(session, "thread.count")
? renderEmpty("当前监控来源未提供线程指标")
: (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={chartMargin}>
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} vertical={false} />
<XAxis dataKey="timeLabel" tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} minTickGap={32} />
<YAxis tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} width={42} />
<RechartsTooltip contentStyle={tooltipStyle} />
<Legend {...legendProps} />
<Line type="monotone" dataKey="threadCount" name="线程数" stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="daemonThreadCount" name="守护线程数" stroke="#13c2c2" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="peakThreadCount" name="线程峰值" stroke="#faad14" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
),
)}
</Col>
<Col xs={24} xl={12}>
{renderCard(
"类加载",
!hasData
? renderEmpty("暂无类加载采样数据")
: !monitoringMetricAvailable(session, "class.loading")
? renderEmpty("当前监控来源未提供类加载指标")
: (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={chartMargin}>
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} vertical={false} />
<XAxis dataKey="timeLabel" tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} minTickGap={32} />
<YAxis tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} tickFormatter={formatCompactNumber} width={58} />
<RechartsTooltip contentStyle={tooltipStyle} />
<Legend {...legendProps} />
<Line type="monotone" dataKey="loadedClassCount" name="已加载类" stroke="#eb2f96" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="unloadedClassCount" name="已卸载类" stroke="#8c8c8c" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
),
)}
</Col>
</Row>
);
};
export default JVMMonitoringCharts;

View File

@@ -0,0 +1,69 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { JVMMonitoringSessionState } from "../../types";
import JVMMonitoringDetailPanel from "./JVMMonitoringDetailPanel";
describe("JVMMonitoringDetailPanel", () => {
it("explains why process physical memory can be unavailable for JMX", () => {
const session: JVMMonitoringSessionState = {
connectionId: "conn-1",
providerMode: "jmx",
running: true,
missingMetrics: ["memory.rss"],
availableMetrics: ["memory.virtual"],
providerWarnings: [],
};
const markup = renderToStaticMarkup(
<JVMMonitoringDetailPanel
session={session}
latestPoint={{
timestamp: 1713945600000,
committedVirtualMemoryBytes: 385 * 1024 * 1024,
}}
darkMode={false}
/>,
);
expect(markup).toContain("进程物理内存");
expect(markup).toContain("JMX 连接未暴露进程驻留物理内存属性");
expect(markup).toContain("HTTP 端点或增强代理");
expect(markup).not.toContain("CommittedVirtualMemorySize");
expect(markup).not.toContain("Endpoint/Agent");
});
it("renders thread state names with Chinese semantic labels", () => {
const session: JVMMonitoringSessionState = {
connectionId: "conn-1",
providerMode: "jmx",
running: true,
missingMetrics: [],
availableMetrics: ["thread.states"],
providerWarnings: [],
};
const markup = renderToStaticMarkup(
<JVMMonitoringDetailPanel
session={session}
latestPoint={{
timestamp: 1713945600000,
threadStateCounts: {
WAITING: 12,
RUNNABLE: 11,
TIMED_WAITING: 10,
},
}}
darkMode={false}
/>,
);
expect(markup).toContain("等待中 12");
expect(markup).toContain("可运行 11");
expect(markup).toContain("限时等待 10");
expect(markup).not.toContain("WAITING 12");
expect(markup).not.toContain("RUNNABLE 11");
expect(markup).not.toContain("TIMED_WAITING 10");
});
});

View File

@@ -0,0 +1,154 @@
import React from "react";
import { Alert, Card, Descriptions, Empty, List, Space, Tag, Typography } from "antd";
import type { JVMMonitoringPoint, JVMMonitoringSessionState } from "../../types";
import {
buildMonitoringAvailabilityText,
extractThreadStateRows,
formatBytes,
formatCompactNumber,
formatPercent,
formatRecentGCLabel,
} from "../../utils/jvmMonitoringPresentation";
const { Paragraph, Text } = Typography;
type JVMMonitoringDetailPanelProps = {
session: JVMMonitoringSessionState;
latestPoint?: JVMMonitoringPoint;
darkMode: boolean;
};
const buildCardStyle = (darkMode: boolean): React.CSSProperties => ({
borderRadius: 12,
background: darkMode ? "#1f1f1f" : "#ffffff",
boxShadow: "0 1px 2px rgba(5, 5, 5, 0.06)",
});
const buildProcessMemoryMissingHint = (
session: JVMMonitoringSessionState,
): string | null => {
if (!(session.missingMetrics || []).includes("memory.rss")) {
return null;
}
if (session.providerMode === "jmx") {
return "JMX 连接未暴露进程驻留物理内存属性,当前只能读取进程虚拟内存指标;如需进程物理内存,请切换到 HTTP 端点或增强代理采集。";
}
return "当前监控来源未返回进程驻留物理内存指标;请确认 HTTP 端点或增强代理已采集并上报进程物理内存。";
};
const JVMMonitoringDetailPanel: React.FC<JVMMonitoringDetailPanelProps> = ({
session,
latestPoint,
darkMode,
}) => {
const threadRows = extractThreadStateRows(latestPoint);
const recentGcEvents = session.recentGcEvents || [];
const missingMetrics = session.missingMetrics || [];
const processMemoryMissingHint = buildProcessMemoryMissingHint(session);
return (
<Space direction="vertical" size={16} style={{ width: "100%" }}>
<Card variant="borderless" title="排障指标" style={buildCardStyle(darkMode)}>
<Descriptions column={1} size="small">
<Descriptions.Item label="进程 CPU">
{formatPercent(latestPoint?.processCpuLoad)}
</Descriptions.Item>
<Descriptions.Item label="系统 CPU">
{formatPercent(latestPoint?.systemCpuLoad)}
</Descriptions.Item>
<Descriptions.Item label="进程物理内存">
{formatBytes(latestPoint?.processRssBytes)}
</Descriptions.Item>
<Descriptions.Item label="进程虚拟内存">
{formatBytes(latestPoint?.committedVirtualMemoryBytes)}
</Descriptions.Item>
</Descriptions>
{processMemoryMissingHint ? (
<Alert
type="info"
showIcon
message="进程物理内存缺失原因"
description={processMemoryMissingHint}
style={{ marginTop: 12 }}
/>
) : null}
</Card>
<Card variant="borderless" title="线程状态分布" style={buildCardStyle(darkMode)}>
{threadRows.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无线程状态采样" />
) : (
<Space wrap size={[8, 8]}>
{threadRows.map((item) => (
<Tag key={item.state} color="blue">
{item.label} {formatCompactNumber(item.count)}
</Tag>
))}
</Space>
)}
</Card>
<Card variant="borderless" title="最近垃圾回收明细" style={buildCardStyle(darkMode)}>
{recentGcEvents.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
missingMetrics.includes("gc.events")
? "当前监控来源未提供事件级垃圾回收数据"
: "最近窗口暂无垃圾回收事件"
}
/>
) : (
<List
dataSource={recentGcEvents}
renderItem={(event) => (
<List.Item>
<List.Item.Meta
title={formatRecentGCLabel(event)}
description={
<Space size={12} wrap>
{typeof event.beforeUsedBytes === "number" ? (
<Text type="secondary">
{formatBytes(event.beforeUsedBytes)}
</Text>
) : null}
{typeof event.afterUsedBytes === "number" ? (
<Text type="secondary">
{formatBytes(event.afterUsedBytes)}
</Text>
) : null}
{event.action ? <Tag>{event.action}</Tag> : null}
</Space>
}
/>
</List.Item>
)}
/>
)}
</Card>
<Card variant="borderless" title="能力与降级" style={buildCardStyle(darkMode)}>
<Paragraph type="secondary" style={{ whiteSpace: "pre-wrap", marginBottom: 12 }}>
{buildMonitoringAvailabilityText(session)}
</Paragraph>
<Space size={[8, 8]} wrap>
{(session.missingMetrics || []).map((metric) => (
<Tag key={metric} color="warning">
{metric}
</Tag>
))}
{(session.providerWarnings || []).map((warning, index) => (
<Tag key={`${warning}-${index}`} color="default">
{warning}
</Tag>
))}
</Space>
</Card>
</Space>
);
};
export default JVMMonitoringDetailPanel;

View File

@@ -0,0 +1,47 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import JVMMonitoringStatusCards from "./JVMMonitoringStatusCards";
describe("JVMMonitoringStatusCards", () => {
it("renders monitoring summary labels in Chinese", () => {
const markup = renderToStaticMarkup(
<JVMMonitoringStatusCards
darkMode={false}
session={{
connectionId: "conn-1",
providerMode: "jmx",
running: true,
}}
latestPoint={{
timestamp: 1713945600000,
heapUsedBytes: 64 * 1024 * 1024,
heapCommittedBytes: 128 * 1024 * 1024,
gcCollectionCount: 20,
gcCollectionTimeMs: 50,
threadCount: 33,
peakThreadCount: 44,
threadStateCounts: {
RUNNABLE: 11,
},
loadedClassCount: 13282,
}}
/>,
);
expect(markup).toContain("堆内存");
expect(markup).toContain("已提交");
expect(markup).toContain("垃圾回收压力");
expect(markup).toContain("累计 50ms");
expect(markup).toContain("线程");
expect(markup).toContain("峰值 44");
expect(markup).toContain("可运行 11");
expect(markup).toContain("类加载");
expect(markup).not.toContain("Committed");
expect(markup).not.toContain("Total");
expect(markup).not.toContain("Peak");
expect(markup).not.toContain("RUNNABLE");
expect(markup).not.toContain("ClassLoading");
});
});

View File

@@ -0,0 +1,92 @@
import React from "react";
import { Card, Col, Row, Space, Statistic, Tag, Typography } from "antd";
import type { JVMMonitoringPoint, JVMMonitoringSessionState } from "../../types";
import {
formatBytes,
formatCompactNumber,
formatDurationMs,
resolveThreadStateLabel,
} from "../../utils/jvmMonitoringPresentation";
const { Text } = Typography;
type JVMMonitoringStatusCardsProps = {
latestPoint?: JVMMonitoringPoint;
session?: JVMMonitoringSessionState;
darkMode: boolean;
};
const cardStyle = (darkMode: boolean): React.CSSProperties => ({
borderRadius: 12,
background: darkMode ? "#1f1f1f" : "#ffffff",
boxShadow: "0 1px 2px rgba(5, 5, 5, 0.06)",
});
const JVMMonitoringStatusCards: React.FC<JVMMonitoringStatusCardsProps> = ({
latestPoint,
session,
darkMode,
}) => {
const runnableCount = latestPoint?.threadStateCounts?.RUNNABLE || 0;
const heapMeta =
latestPoint?.heapCommittedBytes && latestPoint.heapCommittedBytes > 0
? `已提交 ${formatBytes(latestPoint.heapCommittedBytes)}`
: "等待采样";
const gcMeta =
typeof latestPoint?.gcDeltaTimeMs === "number" && latestPoint.gcDeltaTimeMs >= 0
? `Δ ${formatDurationMs(latestPoint.gcDeltaTimeMs)}`
: typeof latestPoint?.gcCollectionTimeMs === "number"
? `累计 ${formatDurationMs(latestPoint.gcCollectionTimeMs)}`
: "等待采样";
const threadMeta =
latestPoint?.peakThreadCount && latestPoint.peakThreadCount > 0
? `峰值 ${formatCompactNumber(latestPoint.peakThreadCount)}`
: "等待采样";
const classMeta =
typeof latestPoint?.classLoadDelta === "number"
? `Δ ${formatCompactNumber(latestPoint.classLoadDelta)}`
: "等待采样";
const runnableLabel = resolveThreadStateLabel("RUNNABLE");
return (
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} xl={6}>
<Card variant="borderless" style={cardStyle(darkMode)} title="堆内存">
<Statistic value={formatBytes(latestPoint?.heapUsedBytes)} />
<Text type="secondary">{heapMeta}</Text>
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card variant="borderless" style={cardStyle(darkMode)} title="垃圾回收压力">
<Statistic
value={formatCompactNumber(
latestPoint?.gcDeltaCount ?? latestPoint?.gcCollectionCount,
)}
/>
<Text type="secondary">{gcMeta}</Text>
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card variant="borderless" style={cardStyle(darkMode)} title="线程">
<Statistic value={formatCompactNumber(latestPoint?.threadCount)} />
<Space size={8} wrap>
<Text type="secondary">{threadMeta}</Text>
{runnableCount > 0 ? <Tag color="blue">{runnableLabel} {runnableCount}</Tag> : null}
</Space>
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card variant="borderless" style={cardStyle(darkMode)} title="类加载">
<Statistic value={formatCompactNumber(latestPoint?.loadedClassCount)} />
<Space size={8} wrap>
<Text type="secondary">{classMeta}</Text>
{session?.running ? <Tag color="green"></Tag> : <Tag></Tag>}
</Space>
</Card>
</Col>
</Row>
);
};
export default JVMMonitoringStatusCards;

View File

@@ -0,0 +1,128 @@
import React from "react";
import { Card, Typography } from "antd";
const { Paragraph, Text } = Typography;
type JVMWorkspaceShellProps = React.HTMLAttributes<HTMLDivElement> & {
darkMode?: boolean;
};
type JVMWorkspaceHeroProps = {
darkMode?: boolean;
eyebrow: string;
title: string;
description?: React.ReactNode;
badges?: React.ReactNode;
actions?: React.ReactNode;
};
export const getJVMWorkspaceCardStyle = (
darkMode?: boolean,
): React.CSSProperties => ({
borderRadius: 18,
boxShadow: darkMode
? "0 16px 38px rgba(0, 0, 0, 0.26)"
: "0 18px 44px rgba(24, 54, 96, 0.08)",
});
const getShellBackground = (darkMode?: boolean): string =>
darkMode
? "linear-gradient(135deg, #101820 0%, #141414 48%, #1f1f1f 100%)"
: "linear-gradient(135deg, #eef4ff 0%, #f7f9fc 45%, #ffffff 100%)";
const getHeroBackground = (darkMode?: boolean): string =>
darkMode
? "linear-gradient(135deg, rgba(22,119,255,0.22), rgba(82,196,26,0.08))"
: "linear-gradient(135deg, rgba(22,119,255,0.14), rgba(19,194,194,0.08))";
export const JVMWorkspaceShell: React.FC<JVMWorkspaceShellProps> = ({
children,
darkMode,
style,
...rest
}) => (
<div
{...rest}
data-jvm-workspace-shell="true"
style={{
height: "100%",
minHeight: 0,
overflowY: "auto",
overflowX: "hidden",
padding: 24,
display: "grid",
gap: 18,
alignContent: "start",
background: getShellBackground(darkMode),
...style,
}}
>
{children}
</div>
);
export const JVMWorkspaceHero: React.FC<JVMWorkspaceHeroProps> = ({
darkMode,
eyebrow,
title,
description,
badges,
actions,
}) => (
<Card
data-jvm-workspace-hero="true"
variant="borderless"
style={{
...getJVMWorkspaceCardStyle(darkMode),
background: getHeroBackground(darkMode),
border: darkMode
? "1px solid rgba(255,255,255,0.08)"
: "1px solid rgba(22,119,255,0.12)",
}}
>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 320px), 1fr))",
gap: 18,
alignItems: "center",
}}
>
<div style={{ minWidth: 0 }}>
<Text type="secondary">{eyebrow}</Text>
<Typography.Title level={3} style={{ margin: "4px 0 8px" }}>
{title}
</Typography.Title>
{description ? (
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
{description}
</Paragraph>
) : null}
{badges ? (
<div
style={{
display: "flex",
gap: 8,
flexWrap: "wrap",
marginTop: 14,
}}
>
{badges}
</div>
) : null}
</div>
{actions ? (
<div
style={{
display: "flex",
gap: 10,
flexWrap: "wrap",
justifyContent: "flex-end",
}}
>
{actions}
</div>
) : null}
</div>
</Card>
);

View File

@@ -25,4 +25,9 @@ describe('buildRedisWorkbenchTheme', () => {
expect(lightTheme.statusTagBg).not.toBe(lightTheme.statusTagMutedBg);
expect(lightTheme.backdropFilter).toBe('none');
});
it('can disable redis workbench blur for macOS text-entry compatibility', () => {
const darkTheme = buildRedisWorkbenchTheme({ darkMode: true, opacity: 0.72, blur: 14, disableBackdropFilter: true });
expect(darkTheme.backdropFilter).toBe('none');
});
});

View File

@@ -1,7 +1,10 @@
import { resolveTextInputSafeBackdropFilter } from '../utils/appearance';
type RedisWorkbenchThemeInput = {
darkMode: boolean;
opacity: number;
blur: number;
disableBackdropFilter?: boolean;
};
type RedisWorkbenchTheme = {
@@ -43,10 +46,15 @@ export const buildRedisWorkbenchTheme = ({
darkMode,
opacity,
blur,
disableBackdropFilter,
}: RedisWorkbenchThemeInput): RedisWorkbenchTheme => {
const normalizedOpacity = clamp(opacity, 0.1, 1);
const normalizedBlur = Math.max(0, Math.round(blur));
const isTranslucent = normalizedOpacity < 0.999 || normalizedBlur > 0;
const backdropFilter = resolveTextInputSafeBackdropFilter(
normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
disableBackdropFilter ?? false,
);
if (darkMode) {
const appTopAlpha = isTranslucent ? Math.max(0.08, Math.min(0.22, normalizedOpacity * 0.16)) : 0.92;
@@ -84,7 +92,7 @@ export const buildRedisWorkbenchTheme = ({
treeSelectedBorder: 'rgba(246, 196, 83, 0.24)',
divider: 'rgba(255, 255, 255, 0.07)',
shadow: '0 20px 48px rgba(0, 0, 0, 0.26)',
backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
backdropFilter,
};
}
@@ -122,7 +130,7 @@ export const buildRedisWorkbenchTheme = ({
treeSelectedBorder: 'rgba(22, 119, 255, 0.18)',
divider: 'rgba(15, 23, 42, 0.08)',
shadow: '0 22px 52px rgba(15, 23, 42, 0.08)',
backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
backdropFilter,
};
};

View File

@@ -5,7 +5,9 @@ import { supportsTableTruncateAction } from './tableDataDangerActions';
describe('tableDataDangerActions', () => {
it('supports native truncate for known relational dialects', () => {
expect(supportsTableTruncateAction('mysql')).toBe(true);
expect(supportsTableTruncateAction('oceanbase')).toBe(true);
expect(supportsTableTruncateAction('postgres')).toBe(true);
expect(supportsTableTruncateAction('opengauss')).toBe(true);
expect(supportsTableTruncateAction('custom', 'postgresql')).toBe(true);
expect(supportsTableTruncateAction('custom', 'kingbase8')).toBe(true);
});

View File

@@ -9,6 +9,10 @@ const resolveCustomDriverDialect = (driver: string): string => {
case 'pq':
case 'pgx':
return 'postgres';
case 'opengauss':
case 'open_gauss':
case 'open-gauss':
return 'opengauss';
case 'dm':
case 'dameng':
case 'dm8':
@@ -21,6 +25,8 @@ const resolveCustomDriverDialect = (driver: string): string => {
case 'diros':
case 'doris':
return 'diros';
case 'oceanbase':
return 'oceanbase';
case 'kingbase':
case 'kingbase8':
case 'kingbasees':
@@ -34,7 +40,9 @@ const resolveCustomDriverDialect = (driver: string): string => {
break;
}
if (normalized.includes('opengauss') || normalized.includes('open_gauss') || normalized.includes('open-gauss')) return 'opengauss';
if (normalized.includes('postgres')) return 'postgres';
if (normalized.includes('oceanbase')) return 'oceanbase';
if (normalized.includes('kingbase')) return 'kingbase';
if (normalized.includes('highgo')) return 'highgo';
if (normalized.includes('vastbase')) return 'vastbase';
@@ -56,10 +64,12 @@ export const supportsTableTruncateAction = (type: string, driver?: string): bool
switch (resolveTableDataActionDBType(type, driver)) {
case 'mysql':
case 'mariadb':
case 'oceanbase':
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase':
case 'opengauss':
case 'sqlserver':
case 'oracle':
case 'dameng':

View File

@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
import {
buildCreateTablePreviewSql,
buildAlterTablePreviewSql,
hasAlterTableDraftChanges,
type BuildAlterTablePreviewInput,
type EditableColumnSnapshot,
} from './tableDesignerSchemaSql';
@@ -29,6 +31,18 @@ const buildInput = (overrides: Partial<BuildAlterTablePreviewInput>): BuildAlter
});
describe('tableDesignerSchemaSql', () => {
it('detects when alter table drafts contain unsaved column changes', () => {
expect(hasAlterTableDraftChanges(buildInput({ dbType: 'mysql' }))).toBe(true);
expect(
hasAlterTableDraftChanges(
buildInput({
dbType: 'mysql',
columns: [baseColumn({ _key: 'id', name: 'id', key: 'PRI', nullable: 'NO' })],
}),
),
).toBe(false);
});
it('keeps mysql alter preview syntax with column position clauses', () => {
const sql = buildAlterTablePreviewSql(buildInput({ dbType: 'mysql' }));
@@ -51,4 +65,152 @@ describe('tableDesignerSchemaSql', () => {
expect(sql).not.toContain('AFTER');
expect(sql).not.toContain(' FIRST');
});
it('uses mysql change column syntax when renaming a column', () => {
const sql = buildAlterTablePreviewSql(buildInput({
dbType: 'mysql',
originalColumns: [baseColumn({ _key: 'name', name: 'name', type: 'varchar(64)', nullable: 'YES' })],
columns: [baseColumn({ _key: 'name', name: 'display_name', type: 'varchar(64)', nullable: 'YES' })],
}));
expect(sql).toContain('CHANGE COLUMN `name` `display_name` varchar(64) NULL');
expect(sql).toContain('FIRST');
expect(sql).not.toContain('MODIFY COLUMN `display_name`');
});
it('builds oracle alter preview with oracle rename and modify syntax', () => {
const sql = buildAlterTablePreviewSql(buildInput({
dbType: 'oracle',
tableName: 'HR.EMPLOYEES',
originalColumns: [
baseColumn({ _key: 'name', name: 'NAME', type: 'VARCHAR2(64)', nullable: 'YES', comment: '旧名称' }),
],
columns: [
baseColumn({
_key: 'name',
name: 'DISPLAY_NAME',
type: 'VARCHAR2(128)',
nullable: 'NO',
default: 'guest',
comment: '显示名',
}),
],
}));
expect(sql).toContain('ALTER TABLE "HR"."EMPLOYEES"\nRENAME COLUMN "NAME" TO "DISPLAY_NAME";');
expect(sql).toContain(`ALTER TABLE "HR"."EMPLOYEES"\nMODIFY ("DISPLAY_NAME" VARCHAR2(128) DEFAULT 'guest' NOT NULL);`);
expect(sql).toContain(`COMMENT ON COLUMN "HR"."EMPLOYEES"."DISPLAY_NAME" IS '显示名';`);
expect(sql).not.toContain('`');
expect(sql).not.toContain('CHANGE COLUMN');
expect(sql).not.toContain('AUTO_INCREMENT');
});
it('builds sqlserver alter preview with sp_rename and alter column syntax', () => {
const sql = buildAlterTablePreviewSql(buildInput({
dbType: 'sqlserver',
tableName: 'dbo.Users',
originalColumns: [
baseColumn({ _key: 'name', name: 'name', type: 'nvarchar(64)', nullable: 'YES' }),
],
columns: [
baseColumn({ _key: 'name', name: 'display_name', type: 'nvarchar(128)', nullable: 'NO' }),
],
}));
expect(sql).toContain(`EXEC sp_rename 'dbo.Users.name', 'display_name', 'COLUMN';`);
expect(sql).toContain('ALTER TABLE [dbo].[Users]\nALTER COLUMN [display_name] nvarchar(128) NOT NULL;');
expect(sql).not.toContain('CHANGE COLUMN');
expect(sql).not.toContain('MODIFY COLUMN');
expect(sql).not.toContain('`');
});
it('keeps sqlite alter preview limited to sqlite-supported operations', () => {
const sql = buildAlterTablePreviewSql(buildInput({
dbType: 'sqlite',
tableName: 'users',
originalColumns: [
baseColumn({ _key: 'name', name: 'name', type: 'TEXT', nullable: 'YES' }),
],
columns: [
baseColumn({ _key: 'name', name: 'display_name', type: 'INTEGER', nullable: 'NO' }),
],
}));
expect(sql).toContain('ALTER TABLE "users"\nRENAME COLUMN "name" TO "display_name";');
expect(sql).toContain('-- SQLite 不支持直接修改字段属性');
expect(sql).not.toContain('CHANGE COLUMN');
expect(sql).not.toContain('MODIFY COLUMN');
expect(sql).not.toContain('AFTER');
});
it('builds duckdb alter preview without mysql-only syntax', () => {
const sql = buildAlterTablePreviewSql(buildInput({
dbType: 'duckdb',
tableName: 'main.users',
originalColumns: [
baseColumn({ _key: 'score', name: 'score', type: 'INTEGER', nullable: 'YES', default: '0' }),
],
columns: [
baseColumn({ _key: 'score', name: 'score', type: 'BIGINT', nullable: 'NO', default: '1' }),
],
}));
expect(sql).toContain('ALTER TABLE "main"."users"\nALTER COLUMN "score" SET DATA TYPE BIGINT;');
expect(sql).toContain('ALTER TABLE "main"."users"\nALTER COLUMN "score" SET DEFAULT 1;');
expect(sql).toContain('ALTER TABLE "main"."users"\nALTER COLUMN "score" SET NOT NULL;');
expect(sql).not.toContain('CHANGE COLUMN');
expect(sql).not.toContain('MODIFY COLUMN');
});
it('uses native limited alter syntax for clickhouse and tdengine instead of mysql syntax', () => {
const clickhouseSql = buildAlterTablePreviewSql(buildInput({
dbType: 'clickhouse',
tableName: 'events',
originalColumns: [baseColumn({ _key: 'name', name: 'name', type: 'String', nullable: 'YES' })],
columns: [baseColumn({ _key: 'name', name: 'display_name', type: 'String', nullable: 'YES' })],
}));
const tdengineSql = buildAlterTablePreviewSql(buildInput({
dbType: 'tdengine',
tableName: 'meters',
originalColumns: [baseColumn({ _key: 'value', name: 'value', type: 'FLOAT', nullable: 'YES' })],
columns: [baseColumn({ _key: 'value', name: 'value', type: 'DOUBLE', nullable: 'YES' })],
}));
expect(clickhouseSql).toContain('ALTER TABLE `events`\nRENAME COLUMN `name` TO `display_name`;');
expect(tdengineSql).toContain('ALTER TABLE `meters`\nMODIFY COLUMN `value` DOUBLE;');
expect(clickhouseSql).not.toContain('CHANGE COLUMN');
expect(tdengineSql).not.toContain('CHANGE COLUMN');
expect(clickhouseSql).not.toContain('AFTER');
expect(tdengineSql).not.toContain('AFTER');
});
it('treats mariadb doris and sphinx as mysql-family only where mysql syntax is intended', () => {
for (const dbType of ['mariadb', 'diros', 'sphinx']) {
const sql = buildAlterTablePreviewSql(buildInput({ dbType }));
expect(sql).toContain('ALTER TABLE `users`');
expect(sql).toContain('ADD COLUMN `age` int NULL');
}
});
it('builds oracle create table preview without mysql table options', () => {
const sql = buildCreateTablePreviewSql({
dbType: 'oracle',
tableName: 'HR.EMPLOYEES',
charset: 'utf8mb4',
collation: 'utf8mb4_unicode_ci',
columns: [
baseColumn({ _key: 'id', name: 'ID', type: 'NUMBER(10)', nullable: 'NO', key: 'PRI', isAutoIncrement: true }),
baseColumn({ _key: 'name', name: 'NAME', type: 'VARCHAR2(255)', nullable: 'YES', comment: '姓名' }),
],
});
expect(sql).toContain('CREATE TABLE "HR"."EMPLOYEES"');
expect(sql).toContain('"ID" NUMBER(10) GENERATED BY DEFAULT AS IDENTITY NOT NULL');
expect(sql).toContain('PRIMARY KEY ("ID")');
expect(sql).toContain(`COMMENT ON COLUMN "HR"."EMPLOYEES"."NAME" IS '姓名';`);
expect(sql).not.toContain('ENGINE=InnoDB');
expect(sql).not.toContain('DEFAULT CHARSET');
expect(sql).not.toContain('AUTO_INCREMENT');
expect(sql).not.toContain('`');
});
});

View File

@@ -1,3 +1,16 @@
import {
isBacktickIdentifierDialect,
isMysqlFamilyDialect,
isOracleLikeDialect,
isPgLikeDialect,
isSqlServerDialect,
quoteSqlIdentifierPart,
quoteSqlIdentifierPath,
resolveSqlDialect,
unquoteSqlIdentifierPart,
unquoteSqlIdentifierPath,
} from '../utils/sqlDialect';
export interface EditableColumnSnapshot {
_key: string;
name: string;
@@ -17,21 +30,17 @@ export interface BuildAlterTablePreviewInput {
columns: EditableColumnSnapshot[];
}
const escapeSqlString = (value: string) => String(value || '').replace(/'/g, "''");
const escapeBacktickIdentifier = (value: string) => String(value || '').replace(/`/g, '``');
const escapeDoubleQuoteIdentifier = (value: string) => String(value || '').replace(/"/g, '""');
export interface BuildCreateTablePreviewInput {
dbType: string;
tableName: string;
columns: EditableColumnSnapshot[];
charset?: string;
collation?: string;
}
const stripIdentifierQuotes = (part: string): string => {
const text = String(part || '').trim();
if (!text) return '';
if ((text.startsWith('`') && text.endsWith('`')) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1).trim();
}
if (text.startsWith('[') && text.endsWith(']')) {
return text.slice(1, -1).replace(/]]/g, ']').trim();
}
return text;
};
const escapeSqlString = (value: string) => String(value || '').replace(/'/g, "''");
const stripIdentifierQuotes = unquoteSqlIdentifierPart;
const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => {
const raw = String(qualifiedName || '').trim();
@@ -44,110 +53,158 @@ const splitQualifiedName = (qualifiedName: string): { schemaName: string; object
};
};
const isMysqlLikeDialect = (dbType: string): boolean => dbType === 'mysql';
const isPgLikeDialect = (dbType: string): boolean =>
dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase';
const quoteIdentifierPart = (part: string, dbType: string): string => quoteSqlIdentifierPart(dbType, part);
const needsPgLikeQuote = (ident: string): boolean => !/^[a-z_][a-z0-9_]*$/.test(ident);
const quoteIdentifierPath = (path: string, dbType: string): string => quoteSqlIdentifierPath(dbType, path);
const quoteIdentifierPart = (part: string, dbType: string): string => {
const ident = stripIdentifierQuotes(part);
if (!ident) return '';
if (isMysqlLikeDialect(dbType)) {
return `\`${escapeBacktickIdentifier(ident)}\``;
}
if (isPgLikeDialect(dbType)) {
if (!needsPgLikeQuote(ident)) {
return ident;
}
return `"${escapeDoubleQuoteIdentifier(ident)}"`;
}
return ident;
const normalizeDefaultText = (value: unknown): string => String(value ?? '').trim();
const isKnownDefaultExpression = (trimmed: string): boolean => {
if (!trimmed) return false;
if (/^N?'.*'$/i.test(trimmed)) return true;
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return true;
if (/^(true|false|null)$/i.test(trimmed)) return true;
if (/^(current_timestamp|current_date|current_time|localtimestamp|sysdate|systimestamp)$/i.test(trimmed)) return true;
if (/^(now|uuid|newid|sysdatetime)\s*\(\s*\)$/i.test(trimmed)) return true;
if (/^nextval\s*\(/i.test(trimmed) || /::/.test(trimmed)) return true;
return false;
};
const quoteIdentifierPath = (path: string, dbType: string): string =>
String(path || '')
.trim()
.split('.')
.map((part) => stripIdentifierQuotes(part))
.filter(Boolean)
.map((part) => quoteIdentifierPart(part, dbType))
.join('.');
const formatPgLikeDefault = (value: string): string => {
const trimmed = String(value || '').trim();
const formatDefaultExpression = (value: unknown, dbType: string): string => {
const trimmed = normalizeDefaultText(value);
if (!trimmed) return '';
if (/^'.*'$/.test(trimmed)) return trimmed;
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
if (/^(true|false|null)$/i.test(trimmed)) return trimmed.toUpperCase() === 'NULL' ? 'NULL' : trimmed.toUpperCase();
if (/^(current_timestamp|current_date|current_time)$/i.test(trimmed)) return trimmed.toUpperCase();
if (/^nextval\s*\(/i.test(trimmed) || /::/.test(trimmed)) return trimmed;
return `'${escapeSqlString(trimmed)}'`;
if (isKnownDefaultExpression(trimmed)) {
if (/^(true|false|null)$/i.test(trimmed)) return trimmed.toUpperCase();
if (/^(current_timestamp|current_date|current_time|localtimestamp|sysdate|systimestamp)$/i.test(trimmed)) {
return trimmed.toUpperCase();
}
return trimmed;
}
const prefix = isSqlServerDialect(dbType) ? 'N' : '';
return `${prefix}'${escapeSqlString(trimmed)}'`;
};
const buildMySqlColumnDefinition = (column: EditableColumnSnapshot): string => {
let extra = String(column.extra || '');
const buildDefaultSql = (value: unknown, dbType: string): string => {
const defaultValue = normalizeDefaultText(value);
if (!defaultValue) return '';
return `DEFAULT ${formatDefaultExpression(defaultValue, dbType)}`;
};
const definitionChanged = (curr: EditableColumnSnapshot, orig: EditableColumnSnapshot): boolean => (
curr.type !== orig.type ||
curr.nullable !== orig.nullable ||
normalizeDefaultText(curr.default) !== normalizeDefaultText(orig.default) ||
(curr.comment || '') !== (orig.comment || '') ||
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement)
);
const physicalDefinitionChanged = (curr: EditableColumnSnapshot, orig: EditableColumnSnapshot): boolean => (
curr.type !== orig.type ||
curr.nullable !== orig.nullable ||
normalizeDefaultText(curr.default) !== normalizeDefaultText(orig.default) ||
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement)
);
const buildMySqlColumnDefinition = (column: EditableColumnSnapshot, dbType: string): string => {
let extra = String(column.extra || '').trim();
if (column.isAutoIncrement) {
if (!extra.toLowerCase().includes('auto_increment')) {
extra += ' AUTO_INCREMENT';
extra = `${extra} AUTO_INCREMENT`.trim();
}
} else {
extra = extra.replace(/auto_increment/gi, '').trim();
}
const defaultSql = column.default ? `DEFAULT '${escapeSqlString(String(column.default))}'` : '';
return `${quoteIdentifierPart(column.name, 'mysql')} ${column.type} ${column.nullable === 'NO' ? 'NOT NULL' : 'NULL'} ${defaultSql} ${extra} COMMENT '${escapeSqlString(column.comment || '')}'`.replace(/\s+/g, ' ').trim();
const defaultSql = buildDefaultSql(column.default, dbType);
return [
quoteIdentifierPart(column.name, dbType),
String(column.type || '').trim(),
column.nullable === 'NO' ? 'NOT NULL' : 'NULL',
defaultSql,
extra,
`COMMENT '${escapeSqlString(column.comment || '')}'`,
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
};
const buildPgLikeColumnDefinition = (column: EditableColumnSnapshot): string => {
const parts = [quoteIdentifierPart(column.name, 'postgres'), String(column.type || '').trim()];
const defaultValue = String(column.default || '').trim();
if (defaultValue) {
parts.push(`DEFAULT ${formatPgLikeDefault(defaultValue)}`);
const buildStandardColumnDefinition = (
column: EditableColumnSnapshot,
dbType: string,
options: { includeNull?: boolean; includeIdentity?: boolean } = {},
): string => {
const parts = [quoteIdentifierPart(column.name, dbType), String(column.type || '').trim()];
if (options.includeIdentity && column.isAutoIncrement) {
if (isSqlServerDialect(dbType)) {
parts.push('IDENTITY(1,1)');
} else if (isOracleLikeDialect(dbType)) {
parts.push('GENERATED BY DEFAULT AS IDENTITY');
}
}
const defaultSql = buildDefaultSql(column.default, dbType);
if (defaultSql) parts.push(defaultSql);
if (column.nullable === 'NO') {
parts.push('NOT NULL');
} else if (options.includeNull) {
parts.push('NULL');
}
return parts.filter(Boolean).join(' ').trim();
};
const buildPgLikeColumnDefinition = (column: EditableColumnSnapshot, dbType: string): string => {
const parts = [quoteIdentifierPart(column.name, dbType), String(column.type || '').trim()];
const defaultSql = buildDefaultSql(column.default, dbType);
if (defaultSql) parts.push(defaultSql);
if (column.nullable === 'NO') parts.push('NOT NULL');
return parts.join(' ').trim();
};
const buildPgLikeCommentSql = (tableRef: string, columnName: string, comment: string): string => {
const columnRef = `${tableRef}.${quoteIdentifierPart(columnName, 'postgres')}`;
const buildColumnCommentSql = (tableRef: string, columnName: string, comment: string, dbType: string): string => {
const columnRef = `${tableRef}.${quoteIdentifierPart(columnName, dbType)}`;
const trimmed = String(comment || '').trim();
if (!trimmed) {
if (!trimmed && isPgLikeDialect(dbType)) {
return `COMMENT ON COLUMN ${columnRef} IS NULL;`;
}
return `COMMENT ON COLUMN ${columnRef} IS '${escapeSqlString(trimmed)}';`;
};
const buildMySqlAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => {
const tableName = quoteIdentifierPath(input.tableName, 'mysql');
const buildSqlServerColumnCommentSql = (
tableName: string,
columnName: string,
comment: string,
): string => {
const { schemaName, objectName } = splitQualifiedName(tableName);
const schema = escapeSqlString(schemaName || 'dbo');
const table = escapeSqlString(objectName || tableName);
const column = escapeSqlString(columnName);
const value = escapeSqlString(comment || '');
return `IF EXISTS (SELECT 1 FROM sys.extended_properties ep JOIN sys.tables t ON ep.major_id = t.object_id JOIN sys.schemas s ON t.schema_id = s.schema_id JOIN sys.columns c ON ep.major_id = c.object_id AND ep.minor_id = c.column_id WHERE ep.name = N'MS_Description' AND s.name = N'${schema}' AND t.name = N'${table}' AND c.name = N'${column}') BEGIN EXEC sp_updateextendedproperty @name = N'MS_Description', @value = N'${value}', @level0type = N'SCHEMA', @level0name = N'${schema}', @level1type = N'TABLE', @level1name = N'${table}', @level2type = N'COLUMN', @level2name = N'${column}' END ELSE BEGIN EXEC sp_addextendedproperty @name = N'MS_Description', @value = N'${value}', @level0type = N'SCHEMA', @level0name = N'${schema}', @level1type = N'TABLE', @level1name = N'${table}', @level2type = N'COLUMN', @level2name = N'${column}' END;`;
};
const buildMySqlAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: string): string => {
const tableName = quoteIdentifierPath(input.tableName, dbType);
const alters: string[] = [];
input.originalColumns.forEach((orig) => {
if (!input.columns.find((col) => col._key === orig._key)) {
alters.push(`DROP COLUMN ${quoteIdentifierPart(orig.name, 'mysql')}`);
alters.push(`DROP COLUMN ${quoteIdentifierPart(orig.name, dbType)}`);
}
});
input.columns.forEach((curr, index) => {
const orig = input.originalColumns.find((col) => col._key === curr._key);
const prevCol = index > 0 ? input.columns[index - 1] : null;
const positionSql = prevCol ? `AFTER ${quoteIdentifierPart(prevCol.name, 'mysql')}` : 'FIRST';
const colDef = buildMySqlColumnDefinition(curr);
const positionSql = prevCol ? `AFTER ${quoteIdentifierPart(prevCol.name, dbType)}` : 'FIRST';
const colDef = buildMySqlColumnDefinition(curr, dbType);
if (!orig) {
alters.push(`ADD COLUMN ${colDef} ${positionSql}`.trim());
return;
}
if (
curr.name !== orig.name ||
curr.type !== orig.type ||
curr.nullable !== orig.nullable ||
curr.default !== orig.default ||
(curr.comment || '') !== (orig.comment || '') ||
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement)
) {
if (curr.name !== orig.name) {
alters.push(`CHANGE COLUMN ${quoteIdentifierPart(orig.name, dbType)} ${colDef} ${positionSql}`.trim());
return;
}
if (definitionChanged(curr, orig)) {
alters.push(`MODIFY COLUMN ${colDef} ${positionSql}`.trim());
}
});
@@ -156,74 +213,65 @@ const buildMySqlAlterPreviewSql = (input: BuildAlterTablePreviewInput): string =
const newPKKeys = input.columns.filter((col) => col.key === 'PRI').map((col) => col._key);
const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key));
if (keysChanged) {
if (origPKKeys.length > 0) {
alters.push('DROP PRIMARY KEY');
}
if (origPKKeys.length > 0) alters.push('DROP PRIMARY KEY');
if (newPKKeys.length > 0) {
const pkNames = input.columns
.filter((col) => col.key === 'PRI')
.map((col) => quoteIdentifierPart(col.name, 'mysql'))
.map((col) => quoteIdentifierPart(col.name, dbType))
.join(', ');
alters.push(`ADD PRIMARY KEY (${pkNames})`);
}
}
if (alters.length === 0) {
return '';
}
return `ALTER TABLE ${tableName}\n${alters.join(',\n')};`;
return alters.length === 0 ? '' : `ALTER TABLE ${tableName}\n${alters.join(',\n')};`;
};
const buildPgLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => {
const buildPgLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: string): string => {
const tableParts = splitQualifiedName(input.tableName);
const baseTableName = tableParts.objectName || stripIdentifierQuotes(input.tableName);
const tableRef = quoteIdentifierPath(input.tableName, 'postgres');
const tableRef = quoteIdentifierPath(input.tableName, dbType);
const statements: string[] = [];
input.originalColumns.forEach((orig) => {
if (!input.columns.find((col) => col._key === orig._key)) {
statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, 'postgres')};`);
statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, dbType)};`);
}
});
input.columns.forEach((curr) => {
const orig = input.originalColumns.find((col) => col._key === curr._key);
if (!orig) {
statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${buildPgLikeColumnDefinition(curr)};`);
if (String(curr.comment || '').trim()) {
statements.push(buildPgLikeCommentSql(tableRef, curr.name, curr.comment || ''));
}
statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${buildPgLikeColumnDefinition(curr, dbType)};`);
if (String(curr.comment || '').trim()) statements.push(buildColumnCommentSql(tableRef, curr.name, curr.comment || '', dbType));
return;
}
let currentName = orig.name;
if (curr.name !== orig.name) {
statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, 'postgres')} TO ${quoteIdentifierPart(curr.name, 'postgres')};`);
statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, dbType)} TO ${quoteIdentifierPart(curr.name, dbType)};`);
currentName = curr.name;
}
if (curr.type !== orig.type) {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} TYPE ${curr.type};`);
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} TYPE ${curr.type};`);
}
const currDefault = String(curr.default || '').trim();
const origDefault = String(orig.default || '').trim();
const currDefault = normalizeDefaultText(curr.default);
const origDefault = normalizeDefaultText(orig.default);
if (currDefault !== origDefault) {
if (currDefault) {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} SET DEFAULT ${formatPgLikeDefault(currDefault)};`);
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} SET DEFAULT ${formatDefaultExpression(currDefault, dbType)};`);
} else {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} DROP DEFAULT;`);
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} DROP DEFAULT;`);
}
}
if (curr.nullable !== orig.nullable) {
statements.push(
`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} ${curr.nullable === 'NO' ? 'SET NOT NULL' : 'DROP NOT NULL'};`,
);
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} ${curr.nullable === 'NO' ? 'SET NOT NULL' : 'DROP NOT NULL'};`);
}
if ((curr.comment || '') !== (orig.comment || '')) {
statements.push(buildPgLikeCommentSql(tableRef, currentName, curr.comment || ''));
statements.push(buildColumnCommentSql(tableRef, currentName, curr.comment || '', dbType));
}
});
@@ -232,12 +280,12 @@ const buildPgLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput): string
const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key));
if (keysChanged) {
if (origPKKeys.length > 0) {
statements.push(`ALTER TABLE ${tableRef}\nDROP CONSTRAINT IF EXISTS ${quoteIdentifierPart(`${baseTableName}_pkey`, 'postgres')};`);
statements.push(`ALTER TABLE ${tableRef}\nDROP CONSTRAINT IF EXISTS ${quoteIdentifierPart(`${baseTableName}_pkey`, dbType)};`);
}
if (newPKKeys.length > 0) {
const pkNames = input.columns
.filter((col) => col.key === 'PRI')
.map((col) => quoteIdentifierPart(col.name, 'postgres'))
.map((col) => quoteIdentifierPart(col.name, dbType))
.join(', ');
statements.push(`ALTER TABLE ${tableRef}\nADD PRIMARY KEY (${pkNames});`);
}
@@ -246,10 +294,322 @@ const buildPgLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput): string
return statements.join('\n');
};
export const buildAlterTablePreviewSql = (input: BuildAlterTablePreviewInput): string => {
const dbType = String(input.dbType || '').trim().toLowerCase();
if (isPgLikeDialect(dbType)) {
return buildPgLikeAlterPreviewSql({ ...input, dbType });
const buildOracleLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: string): string => {
const tableRef = quoteIdentifierPath(input.tableName, dbType);
const statements: string[] = [];
input.originalColumns.forEach((orig) => {
if (!input.columns.find((col) => col._key === orig._key)) {
statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, dbType)};`);
}
});
input.columns.forEach((curr) => {
const orig = input.originalColumns.find((col) => col._key === curr._key);
if (!orig) {
statements.push(`ALTER TABLE ${tableRef}\nADD (${buildStandardColumnDefinition(curr, dbType, { includeIdentity: true })});`);
if (String(curr.comment || '').trim()) statements.push(buildColumnCommentSql(tableRef, curr.name, curr.comment || '', dbType));
return;
}
let currentName = orig.name;
if (curr.name !== orig.name) {
statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, dbType)} TO ${quoteIdentifierPart(curr.name, dbType)};`);
currentName = curr.name;
}
if (physicalDefinitionChanged(curr, orig)) {
statements.push(`ALTER TABLE ${tableRef}\nMODIFY (${buildStandardColumnDefinition({ ...curr, name: currentName }, dbType, { includeIdentity: true })});`);
}
if ((curr.comment || '') !== (orig.comment || '')) {
statements.push(buildColumnCommentSql(tableRef, currentName, curr.comment || '', dbType));
}
});
const origPKKeys = input.originalColumns.filter((col) => col.key === 'PRI').map((col) => col._key);
const newPKKeys = input.columns.filter((col) => col.key === 'PRI').map((col) => col._key);
const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key));
if (keysChanged) {
if (origPKKeys.length > 0) statements.push(`ALTER TABLE ${tableRef}\nDROP PRIMARY KEY;`);
if (newPKKeys.length > 0) {
const pkNames = input.columns.filter((col) => col.key === 'PRI').map((col) => quoteIdentifierPart(col.name, dbType)).join(', ');
statements.push(`ALTER TABLE ${tableRef}\nADD PRIMARY KEY (${pkNames});`);
}
}
return buildMySqlAlterPreviewSql({ ...input, dbType });
return statements.join('\n');
};
const buildSqlServerDefaultDropBatch = (tableName: string, columnName: string): string => {
const { schemaName, objectName } = splitQualifiedName(tableName);
const schema = escapeSqlString(schemaName || 'dbo');
const table = escapeSqlString(objectName || tableName);
const column = escapeSqlString(columnName);
const tableRef = quoteIdentifierPath(`${schemaName || 'dbo'}.${objectName || tableName}`, 'sqlserver');
return `DECLARE @gonavi_df nvarchar(128); SELECT @gonavi_df = dc.name FROM sys.default_constraints dc JOIN sys.columns c ON dc.parent_object_id = c.object_id AND dc.parent_column_id = c.column_id JOIN sys.tables t ON c.object_id = t.object_id JOIN sys.schemas s ON t.schema_id = s.schema_id WHERE s.name = N'${schema}' AND t.name = N'${table}' AND c.name = N'${column}'; IF @gonavi_df IS NOT NULL EXEC(N'ALTER TABLE ${tableRef} DROP CONSTRAINT ' + QUOTENAME(@gonavi_df));`;
};
const buildSqlServerAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => {
const dbType = 'sqlserver';
const tableRef = quoteIdentifierPath(input.tableName, dbType);
const statements: string[] = [];
input.originalColumns.forEach((orig) => {
if (!input.columns.find((col) => col._key === orig._key)) {
statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, dbType)};`);
}
});
input.columns.forEach((curr) => {
const orig = input.originalColumns.find((col) => col._key === curr._key);
if (!orig) {
statements.push(`ALTER TABLE ${tableRef}\nADD ${buildStandardColumnDefinition(curr, dbType, { includeNull: true, includeIdentity: true })};`);
if (String(curr.comment || '').trim()) statements.push(buildSqlServerColumnCommentSql(input.tableName, curr.name, curr.comment || ''));
return;
}
let currentName = orig.name;
if (curr.name !== orig.name) {
const plainTablePath = unquoteSqlIdentifierPath(input.tableName);
statements.push(`EXEC sp_rename '${escapeSqlString(`${plainTablePath}.${orig.name}`)}', '${escapeSqlString(curr.name)}', 'COLUMN';`);
currentName = curr.name;
}
if (curr.type !== orig.type || curr.nullable !== orig.nullable || Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement)) {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${buildStandardColumnDefinition({ ...curr, name: currentName, default: '' }, dbType, { includeNull: true, includeIdentity: false })};`);
}
const currDefault = normalizeDefaultText(curr.default);
const origDefault = normalizeDefaultText(orig.default);
if (currDefault !== origDefault) {
statements.push(buildSqlServerDefaultDropBatch(input.tableName, currentName));
if (currDefault) {
statements.push(`ALTER TABLE ${tableRef}\nADD DEFAULT ${formatDefaultExpression(currDefault, dbType)} FOR ${quoteIdentifierPart(currentName, dbType)};`);
}
}
if ((curr.comment || '') !== (orig.comment || '')) {
statements.push(buildSqlServerColumnCommentSql(input.tableName, currentName, curr.comment || ''));
}
});
const origPKKeys = input.originalColumns.filter((col) => col.key === 'PRI').map((col) => col._key);
const newPKKeys = input.columns.filter((col) => col.key === 'PRI').map((col) => col._key);
const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key));
if (keysChanged) {
const { objectName } = splitQualifiedName(input.tableName);
const constraintName = quoteIdentifierPart(`PK_${objectName || 'table'}`, dbType);
if (origPKKeys.length > 0) {
statements.push(`-- SQL Server 删除旧主键需要原约束名;请先在索引页确认后删除。`);
}
if (newPKKeys.length > 0) {
const pkNames = input.columns.filter((col) => col.key === 'PRI').map((col) => quoteIdentifierPart(col.name, dbType)).join(', ');
statements.push(`ALTER TABLE ${tableRef}\nADD CONSTRAINT ${constraintName} PRIMARY KEY (${pkNames});`);
}
}
return statements.join('\n');
};
const buildSqliteAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => {
const dbType = 'sqlite';
const tableRef = quoteIdentifierPath(input.tableName, dbType);
const statements: string[] = [];
input.originalColumns.forEach((orig) => {
if (!input.columns.find((col) => col._key === orig._key)) {
statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, dbType)};`);
}
});
input.columns.forEach((curr) => {
const orig = input.originalColumns.find((col) => col._key === curr._key);
if (!orig) {
statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${buildStandardColumnDefinition(curr, dbType)};`);
return;
}
let currentName = orig.name;
if (curr.name !== orig.name) {
statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, dbType)} TO ${quoteIdentifierPart(curr.name, dbType)};`);
currentName = curr.name;
}
if (physicalDefinitionChanged(curr, orig) || (curr.comment || '') !== (orig.comment || '')) {
statements.push(`-- SQLite 不支持直接修改字段属性,请通过创建新表、迁移数据、替换旧表的方式处理字段 ${currentName}`);
}
});
return statements.join('\n');
};
const buildDuckDbAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => {
const dbType = 'duckdb';
const tableRef = quoteIdentifierPath(input.tableName, dbType);
const statements: string[] = [];
input.originalColumns.forEach((orig) => {
if (!input.columns.find((col) => col._key === orig._key)) {
statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, dbType)};`);
}
});
input.columns.forEach((curr) => {
const orig = input.originalColumns.find((col) => col._key === curr._key);
if (!orig) {
statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${buildStandardColumnDefinition(curr, dbType)};`);
return;
}
let currentName = orig.name;
if (curr.name !== orig.name) {
statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, dbType)} TO ${quoteIdentifierPart(curr.name, dbType)};`);
currentName = curr.name;
}
if (curr.type !== orig.type) {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} SET DATA TYPE ${curr.type};`);
}
const currDefault = normalizeDefaultText(curr.default);
const origDefault = normalizeDefaultText(orig.default);
if (currDefault !== origDefault) {
if (currDefault) {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} SET DEFAULT ${formatDefaultExpression(currDefault, dbType)};`);
} else {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} DROP DEFAULT;`);
}
}
if (curr.nullable !== orig.nullable) {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} ${curr.nullable === 'NO' ? 'SET NOT NULL' : 'DROP NOT NULL'};`);
}
if ((curr.comment || '') !== (orig.comment || '')) {
statements.push(`-- DuckDB 不支持通过 COMMENT ON COLUMN 持久化字段备注,字段 ${currentName} 的备注仅保留在设计器预览中。`);
}
});
return statements.join('\n');
};
const buildLimitedBacktickAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: string, label: string): string => {
const tableRef = quoteIdentifierPath(input.tableName, dbType);
const statements: string[] = [];
input.originalColumns.forEach((orig) => {
if (!input.columns.find((col) => col._key === orig._key)) {
statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, dbType)};`);
}
});
input.columns.forEach((curr) => {
const orig = input.originalColumns.find((col) => col._key === curr._key);
if (!orig) {
statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${quoteIdentifierPart(curr.name, dbType)} ${curr.type};`);
if (curr.nullable === 'NO' || normalizeDefaultText(curr.default) || String(curr.comment || '').trim()) {
statements.push(`-- ${label} 的字段约束/默认值/备注语法与 MySQL 不同,已避免生成 MySQL 专属子句,请按目标库能力补充。`);
}
return;
}
let currentName = orig.name;
if (curr.name !== orig.name) {
statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, dbType)} TO ${quoteIdentifierPart(curr.name, dbType)};`);
currentName = curr.name;
}
if (curr.type !== orig.type) {
statements.push(`ALTER TABLE ${tableRef}\nMODIFY COLUMN ${quoteIdentifierPart(currentName, dbType)} ${curr.type};`);
}
if (
curr.nullable !== orig.nullable ||
normalizeDefaultText(curr.default) !== normalizeDefaultText(orig.default) ||
(curr.comment || '') !== (orig.comment || '') ||
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement)
) {
statements.push(`-- ${label} 的字段约束/默认值/备注语法与 MySQL 不同,已避免生成 MySQL 专属子句,请按目标库能力补充。`);
}
});
return statements.join('\n');
};
export const buildAlterTablePreviewSql = (input: BuildAlterTablePreviewInput): string => {
const dbType = resolveSqlDialect(input.dbType);
if (isPgLikeDialect(dbType)) return buildPgLikeAlterPreviewSql({ ...input, dbType }, dbType);
if (isOracleLikeDialect(dbType)) return buildOracleLikeAlterPreviewSql({ ...input, dbType }, dbType);
if (isSqlServerDialect(dbType)) return buildSqlServerAlterPreviewSql({ ...input, dbType });
if (dbType === 'sqlite') return buildSqliteAlterPreviewSql({ ...input, dbType });
if (dbType === 'duckdb') return buildDuckDbAlterPreviewSql({ ...input, dbType });
if (dbType === 'clickhouse') return buildLimitedBacktickAlterPreviewSql({ ...input, dbType }, dbType, 'ClickHouse');
if (dbType === 'tdengine') return buildLimitedBacktickAlterPreviewSql({ ...input, dbType }, dbType, 'TDengine');
if (isMysqlFamilyDialect(dbType)) return buildMySqlAlterPreviewSql({ ...input, dbType }, dbType);
return buildPgLikeAlterPreviewSql({ ...input, dbType }, dbType);
};
export const hasAlterTableDraftChanges = (input: BuildAlterTablePreviewInput): boolean =>
buildAlterTablePreviewSql(input).trim().length > 0;
const buildCreateTableColumnDefinition = (column: EditableColumnSnapshot, dbType: string): string => {
if (isMysqlFamilyDialect(dbType)) {
return buildMySqlColumnDefinition(column, dbType);
}
if (isOracleLikeDialect(dbType)) {
return buildStandardColumnDefinition(column, dbType, { includeIdentity: true });
}
if (isSqlServerDialect(dbType)) {
return buildStandardColumnDefinition(column, dbType, { includeNull: true, includeIdentity: true });
}
if (dbType === 'clickhouse' || dbType === 'tdengine') {
return [quoteIdentifierPart(column.name, dbType), String(column.type || '').trim()].join(' ');
}
return buildStandardColumnDefinition(column, dbType);
};
const buildCreateColumnComments = (tableRef: string, input: BuildCreateTablePreviewInput, dbType: string): string[] => (
input.columns
.filter((column) => String(column.comment || '').trim())
.map((column) => {
if (isSqlServerDialect(dbType)) {
return buildSqlServerColumnCommentSql(input.tableName, column.name, column.comment || '');
}
if (isPgLikeDialect(dbType) || isOracleLikeDialect(dbType)) {
return buildColumnCommentSql(tableRef, column.name, column.comment || '', dbType);
}
return '';
})
.filter(Boolean)
);
export const buildCreateTablePreviewSql = (input: BuildCreateTablePreviewInput): string => {
const dbType = resolveSqlDialect(input.dbType);
const tableRef = quoteIdentifierPath(input.tableName, dbType);
const colDefs = input.columns.map((column) => buildCreateTableColumnDefinition(column, dbType));
const pkColumns = input.columns.filter((column) => column.key === 'PRI');
if (pkColumns.length > 0) {
const pkNames = pkColumns.map((column) => quoteIdentifierPart(column.name, dbType)).join(', ');
colDefs.push(`PRIMARY KEY (${pkNames})`);
}
const createSql = `CREATE TABLE ${tableRef} (\n ${colDefs.join(',\n ')}\n)`;
const comments = buildCreateColumnComments(tableRef, input, dbType);
if (dbType === 'mysql' || dbType === 'mariadb') {
const charset = String(input.charset || '').trim();
const collation = String(input.collation || '').trim();
const charsetSql = charset ? ` DEFAULT CHARSET=${charset}` : '';
const collationSql = collation ? ` COLLATE=${collation}` : '';
return `${createSql} ENGINE=InnoDB${charsetSql}${collationSql};`;
}
if (dbType === 'clickhouse') {
return `${createSql}\nENGINE = MergeTree\nORDER BY tuple();`;
}
const suffixComments = comments.length > 0 ? `\n${comments.join('\n')}` : '';
if (dbType === 'tdengine' && !input.columns.some((column) => /^timestamp$/i.test(String(column.type || '').trim()))) {
return `${createSql};\n-- TDengine 普通表通常需要 TIMESTAMP 时间列,执行前请确认表模型。${suffixComments}`;
}
if (isBacktickIdentifierDialect(dbType) && dbType !== 'mysql' && dbType !== 'mariadb') {
return `${createSql};${suffixComments}`;
}
return `${createSql};${suffixComments}`;
};

View File

@@ -0,0 +1,99 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./App', () => ({
default: () => null,
}));
const createRootMock = vi.fn(() => ({
render: vi.fn(),
}));
vi.mock('react-dom/client', () => ({
default: {
createRoot: createRootMock,
},
createRoot: createRootMock,
}));
const dayjsLocaleMock = vi.fn();
vi.mock('dayjs', () => ({
default: Object.assign(() => null, {
locale: dayjsLocaleMock,
}),
}));
vi.mock('dayjs/locale/zh-cn', () => ({}));
const loaderConfigMock = vi.fn();
vi.mock('@monaco-editor/react', () => ({
loader: {
config: loaderConfigMock,
},
}));
const defineThemeMock = vi.fn();
vi.mock('monaco-editor', () => ({
editor: {
defineTheme: defineThemeMock,
},
}));
vi.mock('monaco-editor/esm/nls.messages.zh-cn', () => ({}));
const importMain = async () => {
await import('./main');
return (globalThis as typeof globalThis & {
window: {
go?: {
app?: {
App?: {
ImportConfigFile: () => Promise<{ success: boolean; message?: string }>;
ImportConnectionsPayload: (raw: string, password?: string) => Promise<unknown>;
ExportConnectionsPackage: (options?: { includeSecrets?: boolean; filePassword?: string }) => Promise<{ success: boolean; message?: string }>;
};
};
};
};
}).window.go?.app?.App;
};
describe('main browser mock', () => {
beforeEach(() => {
vi.resetModules();
vi.stubGlobal('window', {});
vi.stubGlobal('document', {
getElementById: vi.fn(() => ({})),
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
vi.resetModules();
});
it('returns explicit browser-mode messages for import picker and package export', async () => {
const app = await importMain();
expect(app).toBeDefined();
await expect(app!.ImportConfigFile()).resolves.toEqual({
success: false,
message: '已取消',
});
await expect(app!.ExportConnectionsPackage({ includeSecrets: true, filePassword: '' })).resolves.toEqual({
success: false,
message: '浏览器 mock 不支持恢复包导出',
});
});
it('rejects non-array payloads instead of treating them as successful imports', async () => {
const app = await importMain();
await expect(app!.ImportConnectionsPayload('{"version":1}')).rejects.toThrow(
'浏览器 mock 不支持恢复包导入,仅支持历史 JSON 连接数组',
);
});
});

View File

@@ -127,11 +127,28 @@ if (typeof window !== 'undefined' && !(window as any).go) {
GetAppInfo: async () => ({}),
GetDataRootDirectoryInfo: async () => ({ success: true, data: cloneBrowserMockValue(mockDataRootInfo) }),
CheckForUpdates: async () => ({ success: false }),
CheckForUpdatesSilently: async () => ({ success: false }),
OpenDownloadedUpdateDirectory: async () => ({ success: false }),
OpenDriverDownloadDirectory: async (path: string) => ({ success: true, data: { path } }),
OpenDataRootDirectory: async () => ({ success: true }),
SelectSQLDirectory: async (currentPath: string) => ({ success: false, message: currentPath ? '已取消' : '已取消' }),
ListSQLDirectory: async () => ({ success: true, data: [] }),
ReadSQLFile: async () => ({ success: false, message: '已取消' }),
WriteSQLFile: async (_filePath: string, _content: string) => ({ success: true }),
InstallUpdateAndRestart: async () => ({ success: false }),
ImportConfigFile: async () => ({ success: false }),
ImportConfigFile: async () => ({ success: false, message: '已取消' }),
ImportConnectionsPayload: async (raw: string, _password?: string) => {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.map((item) => saveMockConnection(item));
}
} catch {
throw new Error('浏览器 mock 不支持恢复包导入,仅支持历史 JSON 连接数组');
}
throw new Error('浏览器 mock 不支持恢复包导入,仅支持历史 JSON 连接数组');
},
ExportConnectionsPackage: async (_options?: { includeSecrets?: boolean; filePassword?: string }) => ({ success: false, message: '浏览器 mock 不支持恢复包导出' }),
ExportData: async () => ({ success: false }),
GetGlobalProxyConfig: async () => ({ success: true, data: cloneBrowserMockValue(mockGlobalProxy) }),
SaveGlobalProxy: async (input: any) => saveMockGlobalProxy(input),

View File

@@ -91,4 +91,439 @@ describe('store appearance persistence', () => {
expect(appearance.showDataTableVerticalBorders).toBe(true);
expect(appearance.dataTableColumnWidthMode).toBe('compact');
});
it('does not clear persisted legacy connections during hydration migration', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {
connections: [
{
id: 'legacy-1',
name: 'Legacy',
config: {
id: 'legacy-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
password: 'secret',
},
},
],
},
version: 7,
}));
const { useStore } = await importStore();
expect(useStore.getState().connections).toHaveLength(1);
expect(useStore.getState().connections[0]?.config.password).toBe('secret');
});
it('preserves JVM Arthas diagnostic config when replacing saved connections', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'jvm-1',
name: 'Orders JVM',
config: {
id: 'jvm-1',
type: 'jvm',
host: '127.0.0.1',
port: 9010,
user: '',
jvm: {
allowedModes: ['jmx'],
preferredMode: 'jmx',
diagnostic: {
enabled: true,
transport: 'arthas-tunnel',
baseUrl: 'http://127.0.0.1:7777',
targetId: 'gonavi-local-test',
apiKey: 'diag-token',
allowObserveCommands: true,
allowTraceCommands: true,
allowMutatingCommands: false,
timeoutSeconds: 20,
},
},
},
},
]);
expect(useStore.getState().connections[0]?.config.jvm?.diagnostic).toEqual({
enabled: true,
transport: 'arthas-tunnel',
baseUrl: 'http://127.0.0.1:7777',
targetId: 'gonavi-local-test',
apiKey: 'diag-token',
allowObserveCommands: true,
allowTraceCommands: true,
allowMutatingCommands: false,
timeoutSeconds: 20,
});
});
it('preserves connection icon metadata when replacing saved connections', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'visual-1',
name: 'Visual Orders',
iconType: 'postgres',
iconColor: '#2f855a',
config: {
id: 'visual-1',
type: 'mysql',
host: 'db.local',
port: 3306,
user: 'root',
},
},
]);
expect(useStore.getState().connections[0]?.iconType).toBe('postgres');
expect(useStore.getState().connections[0]?.iconColor).toBe('#2f855a');
});
it('normalizes ClickHouse protocol override when replacing saved connections', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'clickhouse-http',
name: 'ClickHouse HTTP',
config: {
id: 'clickhouse-http',
type: 'clickhouse',
host: 'clickhouse.local',
port: 8125,
user: 'default',
clickHouseProtocol: 'https' as any,
},
},
]);
expect(useStore.getState().connections[0]?.config.clickHouseProtocol).toBe(
'http',
);
});
it('normalizes OceanBase protocol override when replacing saved connections', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'oceanbase-oracle',
name: 'OceanBase Oracle',
config: {
id: 'oceanbase-oracle',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'sys@oracle001',
oceanBaseProtocol: 'oracle',
},
},
]);
expect(useStore.getState().connections[0]?.config.oceanBaseProtocol).toBe(
'oracle',
);
});
it('restores OceanBase protocol from saved URI or connection params', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'oceanbase-uri-oracle',
name: 'OceanBase URI Oracle',
config: {
id: 'oceanbase-uri-oracle',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'sys@oracle001',
uri: 'oceanbase://sys%40oracle001:pass@ob.local:2881/OBORCL?protocol=oracle',
},
},
{
id: 'oceanbase-param-oracle',
name: 'OceanBase Param Oracle',
config: {
id: 'oceanbase-param-oracle',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'sys@oracle001',
connectionParams: 'tenantMode=oracle&PREFETCH_ROWS=5000',
},
},
]);
expect(useStore.getState().connections[0]?.config.oceanBaseProtocol).toBe(
'oracle',
);
expect(useStore.getState().connections[1]?.config.oceanBaseProtocol).toBe(
'oracle',
);
});
it('prefers OceanBase protocol query key over legacy aliases when restoring saved connections', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'oceanbase-conflict',
name: 'OceanBase Conflict',
config: {
id: 'oceanbase-conflict',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'root@test',
connectionParams: 'protocol=mysql&tenantMode=oracle',
},
},
]);
expect(useStore.getState().connections[0]?.config.oceanBaseProtocol).toBe(
'mysql',
);
});
it('normalizes OceanBase protocol when updating a saved connection', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'oceanbase-existing',
name: 'OceanBase Existing',
config: {
id: 'oceanbase-existing',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'root@test',
connectionParams: 'protocol=mysql',
},
},
]);
useStore.getState().updateConnection({
id: 'oceanbase-existing',
name: 'OceanBase Existing',
config: {
id: 'oceanbase-existing',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'sys@oracle001',
connectionParams: 'protocol=oracle',
},
});
expect(useStore.getState().connections[0]?.config.oceanBaseProtocol).toBe(
'oracle',
);
});
it('keeps legacy global proxy password during hydration until explicit cleanup', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {
globalProxy: {
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
password: 'proxy-secret',
},
},
version: 7,
}));
const { useStore } = await importStore();
expect(useStore.getState().globalProxy.password).toBe('proxy-secret');
expect(useStore.getState().globalProxy.hasPassword).toBe(true);
});
it('persists external SQL directories and restores valid items after reload', async () => {
const { useStore } = await importStore();
useStore.getState().saveExternalSQLDirectory({
id: 'ext-1',
name: 'scripts',
path: 'D:/sql/scripts',
connectionId: 'conn-1',
dbName: 'demo',
createdAt: 1,
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.externalSQLDirectories).toEqual([
{
id: 'ext-1',
name: 'scripts',
path: 'D:/sql/scripts',
connectionId: 'conn-1',
dbName: 'demo',
createdAt: 1,
},
]);
storage.setItem('lite-db-storage', JSON.stringify({
state: {
externalSQLDirectories: [
persisted.state.externalSQLDirectories[0],
{ path: '', name: 'broken' },
],
},
version: 7,
}));
vi.resetModules();
const reloaded = await importStore();
expect(reloaded.useStore.getState().externalSQLDirectories).toEqual([
{
id: 'ext-1',
name: 'scripts',
path: 'D:/sql/scripts',
connectionId: 'conn-1',
dbName: 'demo',
createdAt: 1,
},
]);
});
it('defaults AI chat send shortcut to Enter in shared shortcut options', async () => {
const { useStore } = await importStore();
expect(useStore.getState().shortcutOptions.sendAIChatMessage).toEqual({
combo: 'Enter',
enabled: true,
});
});
it('persists recorded AI chat send shortcut and restores it after reload', async () => {
const { useStore } = await importStore();
useStore.getState().updateShortcut('sendAIChatMessage', {
combo: 'Meta+Enter',
enabled: true,
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
combo: 'Meta+Enter',
enabled: true,
});
vi.resetModules();
const reloaded = await importStore();
expect(reloaded.useStore.getState().shortcutOptions.sendAIChatMessage).toEqual({
combo: 'Meta+Enter',
enabled: true,
});
});
it('falls back to Enter when persisted AI chat send shortcut is invalid', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {
shortcutOptions: {
sendAIChatMessage: {
combo: 'A',
enabled: true,
},
},
},
version: 8,
}));
const { useStore } = await importStore();
expect(useStore.getState().shortcutOptions.sendAIChatMessage).toEqual({
combo: 'Enter',
enabled: true,
});
});
it('does not overwrite recorded AI chat send shortcut during startup config refresh', async () => {
const { useStore } = await importStore();
useStore.getState().updateShortcut('sendAIChatMessage', {
combo: 'Ctrl+Enter',
enabled: true,
});
useStore.getState().replaceConnections([]);
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
combo: 'Ctrl+Enter',
enabled: true,
});
});
it('keeps persisted AI chat send shortcut when startup refresh runs before shortcut hydration catches up', async () => {
const { useStore } = await importStore();
const shortcutOptions = useStore.getState().shortcutOptions;
storage.setItem('lite-db-storage', JSON.stringify({
state: {
shortcutOptions: {
...shortcutOptions,
sendAIChatMessage: {
combo: 'Meta+Enter',
enabled: true,
},
},
},
version: 8,
}));
useStore.setState({
shortcutOptions: {
...shortcutOptions,
sendAIChatMessage: {
combo: 'Enter',
enabled: true,
},
},
});
useStore.getState().replaceConnections([]);
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
combo: 'Meta+Enter',
enabled: true,
});
});
it('does not let a stale default shortcut state overwrite an explicitly recorded AI chat shortcut', async () => {
const { useStore } = await importStore();
const shortcutOptions = useStore.getState().shortcutOptions;
useStore.getState().updateShortcut('sendAIChatMessage', {
combo: 'Meta+Enter',
enabled: true,
});
useStore.setState({
shortcutOptions: {
...shortcutOptions,
sendAIChatMessage: {
combo: 'Enter',
enabled: true,
},
},
});
useStore.getState().replaceGlobalProxy({});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
combo: 'Meta+Enter',
enabled: true,
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ export interface SSHConfig {
}
export interface ProxyConfig {
type: 'socks5' | 'http';
type: "socks5" | "http";
host: string;
port: number;
user?: string;
@@ -21,6 +21,258 @@ export interface HTTPTunnelConfig {
password?: string;
}
export interface JVMJMXConfig {
enabled?: boolean;
host?: string;
port?: number;
username?: string;
password?: string;
domainAllowlist?: string[];
}
export interface JVMEndpointConfig {
enabled?: boolean;
baseUrl?: string;
apiKey?: string;
timeoutSeconds?: number;
}
export interface JVMAgentConfig {
enabled?: boolean;
baseUrl?: string;
apiKey?: string;
timeoutSeconds?: number;
}
export type JVMDiagnosticTransport = "agent-bridge" | "arthas-tunnel";
export interface JVMDiagnosticConfig {
enabled?: boolean;
transport?: JVMDiagnosticTransport;
baseUrl?: string;
targetId?: string;
apiKey?: string;
allowObserveCommands?: boolean;
allowTraceCommands?: boolean;
allowMutatingCommands?: boolean;
timeoutSeconds?: number;
}
export interface JVMDiagnosticCapability {
transport: JVMDiagnosticTransport;
canOpenSession: boolean;
canStream: boolean;
canCancel: boolean;
allowObserveCommands: boolean;
allowTraceCommands: boolean;
allowMutatingCommands: boolean;
reason?: string;
}
export interface JVMDiagnosticSessionRequest {
title?: string;
reason?: string;
}
export interface JVMDiagnosticSessionHandle {
sessionId: string;
transport: string;
startedAt: number;
}
export interface JVMDiagnosticCommandRequest {
sessionId: string;
commandId: string;
command: string;
source?: string;
reason?: string;
}
export interface JVMDiagnosticEventChunk {
sessionId: string;
commandId?: string;
event?: string;
phase?: string;
content?: string;
timestamp?: number;
metadata?: Record<string, any>;
}
export interface JVMDiagnosticAuditRecord {
timestamp: number;
connectionId: string;
sessionId?: string;
commandId?: string;
transport: string;
command: string;
commandType?: string;
source?: string;
reason?: string;
riskLevel?: string;
status: string;
}
export interface JVMDiagnosticPlan {
intent: string;
transport: JVMDiagnosticTransport;
command: string;
riskLevel: "low" | "medium" | "high";
reason: string;
expectedSignals?: string[];
}
export interface JVMDiagnosticCommandDraft {
sessionId?: string;
command: string;
source?: "manual" | "ai-plan";
reason?: string;
}
export interface JVMConfig {
environment?: "dev" | "uat" | "prod";
readOnly?: boolean;
allowedModes?: Array<"jmx" | "endpoint" | "agent">;
preferredMode?: "jmx" | "endpoint" | "agent";
jmx?: JVMJMXConfig;
endpoint?: JVMEndpointConfig;
agent?: JVMAgentConfig;
diagnostic?: JVMDiagnosticConfig;
}
export interface JVMCapability {
mode: "jmx" | "endpoint" | "agent";
canBrowse: boolean;
canWrite: boolean;
canPreview: boolean;
reason?: string;
displayLabel: string;
}
export interface JVMMonitoringPoint {
timestamp: number;
heapUsedBytes?: number;
heapCommittedBytes?: number;
heapMaxBytes?: number;
nonHeapUsedBytes?: number;
nonHeapCommittedBytes?: number;
gcCollectionCount?: number;
gcCollectionTimeMs?: number;
gcDeltaCount?: number;
gcDeltaTimeMs?: number;
threadCount?: number;
daemonThreadCount?: number;
peakThreadCount?: number;
threadStateCounts?: Record<string, number>;
loadedClassCount?: number;
unloadedClassCount?: number;
classLoadDelta?: number;
processCpuLoad?: number;
systemCpuLoad?: number;
processRssBytes?: number;
committedVirtualMemoryBytes?: number;
}
export interface JVMMonitoringRecentGCEvent {
timestamp: number;
name?: string;
cause?: string;
action?: string;
durationMs?: number;
beforeUsedBytes?: number;
afterUsedBytes?: number;
}
export interface JVMMonitoringSessionState {
connectionId: string;
providerMode: "jmx" | "endpoint" | "agent";
running: boolean;
points?: JVMMonitoringPoint[];
recentGcEvents?: JVMMonitoringRecentGCEvent[];
availableMetrics?: string[];
missingMetrics?: string[];
providerWarnings?: string[];
}
export interface JVMResourceSummary {
id: string;
parentId?: string;
kind: string;
name: string;
path: string;
providerMode: "jmx" | "endpoint" | "agent";
canRead: boolean;
canWrite: boolean;
hasChildren: boolean;
sensitive?: boolean;
}
export interface JVMActionPayloadField {
name: string;
type?: string;
required?: boolean;
description?: string;
}
export interface JVMActionDefinition {
action: string;
label?: string;
description?: string;
dangerous?: boolean;
payloadFields?: JVMActionPayloadField[];
payloadExample?: Record<string, any>;
}
export interface JVMValueSnapshot {
resourceId: string;
kind: string;
format: string;
version?: string;
value: any;
description?: string;
sensitive?: boolean;
supportedActions?: JVMActionDefinition[];
metadata?: Record<string, any>;
}
export interface JVMChangePreview {
allowed: boolean;
requiresConfirmation?: boolean;
confirmationToken?: string;
summary: string;
riskLevel: "low" | "medium" | "high";
blockingReason?: string;
before: JVMValueSnapshot;
after: JVMValueSnapshot;
}
export interface JVMChangeRequest {
providerMode: "jmx" | "endpoint" | "agent";
resourceId: string;
action: string;
reason: string;
source?: "manual" | "ai-plan";
expectedVersion?: string;
confirmationToken?: string;
payload?: Record<string, any>;
}
export interface JVMApplyResult {
status: string;
message?: string;
updatedValue: JVMValueSnapshot;
}
export interface JVMAuditRecord {
timestamp: number;
connectionId: string;
providerMode: string;
resourceId: string;
action: string;
reason: string;
source?: string;
result: string;
}
export interface ConnectionConfig {
id?: string;
type: string;
@@ -31,7 +283,7 @@ export interface ConnectionConfig {
savePassword?: boolean;
database?: string;
useSSL?: boolean;
sslMode?: 'preferred' | 'required' | 'skip-verify' | 'disable';
sslMode?: "preferred" | "required" | "skip-verify" | "disable";
sslCertPath?: string;
sslKeyPath?: string;
useSSH?: boolean;
@@ -42,11 +294,14 @@ export interface ConnectionConfig {
httpTunnel?: HTTPTunnelConfig;
driver?: string;
dsn?: string;
connectionParams?: string;
timeout?: number;
redisDB?: number; // Redis database index (0-15)
uri?: string; // Connection URI for copy/paste
clickHouseProtocol?: "auto" | "http" | "native"; // ClickHouse connection protocol override
oceanBaseProtocol?: "mysql" | "oracle"; // OceanBase tenant protocol
hosts?: string[]; // Multi-host addresses: host:port
topology?: 'single' | 'replica' | 'cluster';
topology?: "single" | "replica" | "cluster";
mysqlReplicaUser?: string;
mysqlReplicaPassword?: string;
replicaSet?: string;
@@ -56,6 +311,7 @@ export interface ConnectionConfig {
mongoAuthMechanism?: string;
mongoReplicaUser?: string;
mongoReplicaPassword?: string;
jvm?: JVMConfig;
}
export interface MongoMemberInfo {
@@ -82,8 +338,8 @@ export interface SavedConnection {
hasOpaqueDSN?: boolean;
includeDatabases?: string[];
includeRedisDatabases?: number[]; // Redis databases to show (0-15)
iconType?: string; // 自定义图标类型(如 'mysql','postgres'),不填则取 config.type
iconColor?: string; // 自定义图标颜色(十六进制),不填则取类型默认色
iconType?: string; // 自定义图标类型(如 'mysql','postgres'),不填则取 config.type
iconColor?: string; // 自定义图标颜色(十六进制),不填则取类型默认色
}
export interface GlobalProxyConfig extends ProxyConfig {
@@ -134,13 +390,32 @@ export interface TriggerDefinition {
export interface TabData {
id: string;
title: string;
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'redis-monitor' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview';
type:
| "query"
| "table"
| "design"
| "redis-keys"
| "redis-command"
| "redis-monitor"
| "trigger"
| "view-def"
| "routine-def"
| "table-overview"
| "jvm-overview"
| "jvm-resource"
| "jvm-audit"
| "jvm-diagnostic"
| "jvm-monitoring";
connectionId: string;
dbName?: string;
tableName?: string;
query?: string;
filePath?: string;
initialTab?: string;
readOnly?: boolean;
providerMode?: "jmx" | "endpoint" | "agent";
resourcePath?: string;
resourceKind?: string;
redisDB?: number; // Redis database index for redis tabs
triggerName?: string; // Trigger name for trigger tabs
viewName?: string; // View name for view definition tabs
@@ -149,6 +424,19 @@ export interface TabData {
savedQueryId?: string; // Saved query identity for quick-save behavior
}
export interface JVMAIPlanContext {
tabId: string;
connectionId: string;
providerMode: "jmx" | "endpoint" | "agent";
resourcePath: string;
}
export interface JVMDiagnosticPlanContext {
tabId: string;
connectionId: string;
transport: JVMDiagnosticTransport;
}
export interface DatabaseNode {
title: string;
key: string;
@@ -166,6 +454,22 @@ export interface SavedQuery {
createdAt: number;
}
export interface ExternalSQLDirectory {
id: string;
name: string;
path: string;
connectionId: string;
dbName: string;
createdAt: number;
}
export interface ExternalSQLTreeEntry {
name: string;
path: string;
isDir: boolean;
children?: ExternalSQLTreeEntry[];
}
// Redis types
export interface RedisKeyInfo {
key: string;
@@ -179,7 +483,7 @@ export interface RedisScanResult {
}
export interface RedisValue {
type: 'string' | 'hash' | 'list' | 'set' | 'zset' | 'stream';
type: "string" | "hash" | "list" | "set" | "zset" | "stream";
ttl: number;
value: any;
length: number;
@@ -202,9 +506,9 @@ export interface StreamEntry {
// --- AI Types ---
export type AIProviderType = 'openai' | 'anthropic' | 'gemini' | 'custom';
export type AISafetyLevel = 'readonly' | 'readwrite' | 'full';
export type AIContextLevel = 'schema_only' | 'with_samples' | 'with_results';
export type AIProviderType = "openai" | "anthropic" | "gemini" | "custom";
export type AISafetyLevel = "readonly" | "readwrite" | "full";
export type AIContextLevel = "schema_only" | "with_samples" | "with_results";
export interface AIContextItem {
dbName: string;
@@ -237,14 +541,20 @@ export interface AIToolCall {
};
}
export type ChatPhase = 'idle' | 'connecting' | 'thinking' | 'generating' | 'tool_calling';
export type ChatPhase =
| "idle"
| "connecting"
| "thinking"
| "generating"
| "tool_calling";
export interface AIChatMessage {
id: string;
role: 'user' | 'assistant' | 'system' | 'tool';
role: "user" | "assistant" | "system" | "tool";
phase?: ChatPhase;
content: string;
thinking?: string;
reasoning_content?: string;
timestamp: number;
loading?: boolean;
images?: string[]; // base64 encoded images with data URI prefix
@@ -253,13 +563,88 @@ export interface AIChatMessage {
tool_name?: string; // used for UI display
rawError?: string; // 存储未清洗的原始错误信息,用于用户复制排查
success?: boolean; // 标记探针执行是否成功
jvmPlanContext?: JVMAIPlanContext;
jvmDiagnosticPlanContext?: JVMDiagnosticPlanContext;
}
export interface AISafetyResult {
allowed: boolean;
operationType: 'query' | 'dml' | 'ddl' | 'other';
operationType: "query" | "dml" | "ddl" | "other";
requiresConfirm: boolean;
warningMessage?: string;
}
export type SecurityUpdateOverallStatus =
| "not_detected"
| "pending"
| "postponed"
| "in_progress"
| "needs_attention"
| "completed"
| "rolled_back";
export type SecurityUpdateIssueScope =
| "connection"
| "global_proxy"
| "ai_provider"
| "system";
export type SecurityUpdateIssueSeverity = "high" | "medium" | "low";
export type SecurityUpdateItemStatus =
| "pending"
| "updated"
| "needs_attention"
| "skipped"
| "failed";
export type SecurityUpdateIssueReasonCode =
| "migration_required"
| "secret_missing"
| "field_invalid"
| "write_conflict"
| "validation_failed"
| "environment_blocked";
export type SecurityUpdateIssueAction =
| "open_connection"
| "open_proxy_settings"
| "open_ai_settings"
| "retry_update"
| "view_details";
export interface SecurityUpdateSummary {
total: number;
updated: number;
pending: number;
skipped: number;
failed: number;
}
export interface SecurityUpdateIssue {
id: string;
scope?: SecurityUpdateIssueScope;
refId?: string;
title?: string;
severity?: SecurityUpdateIssueSeverity;
status?: SecurityUpdateItemStatus;
reasonCode?: SecurityUpdateIssueReasonCode;
action?: SecurityUpdateIssueAction;
message?: string;
}
export interface SecurityUpdateStatus {
schemaVersion?: number;
migrationId?: string;
overallStatus: SecurityUpdateOverallStatus;
sourceType?: "current_app_saved_config";
reminderVisible?: boolean;
canStart?: boolean;
canPostpone?: boolean;
canRetry?: boolean;
backupAvailable?: boolean;
backupPath?: string;
startedAt?: string;
updatedAt?: string;
completedAt?: string;
postponedAt?: string;
summary: SecurityUpdateSummary;
issues: SecurityUpdateIssue[];
lastError?: string;
}

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