feat(mcp): P0 只读 MCP tool runtime + opsxdb-mcp stdio

- aitools 共用 runtime:list/describe/schema/query/redis(纯只读,无写确认)
- 白名单 ~/.opsxdb/mcp.json;审计 mcp-audit.jsonl;复用 danger.go
- 桌面 connections 双写 ~/.opsxdb/connections.json(ox1. 解码)
- Cursor 配置示例与任务书验收勾选更新;写操作与 Agent UI 留 P1
This commit is contained in:
logan committed 2026-10-15 11:14:08 +08:00
1 parent 5c17d32647
commit 0b68c8344d
19 files changed
+1982 -72

No files matched your search

+11 -6
View File
@@ -14,25 +14,30 @@ coverage/
.turbo/
.idea/
.vscode/
apps/server/opsxapi
apps/server/server
apps/desktop/src-tauri/gen/
server/opsxapi
server/server
src-tauri/gen/
*.exe
*.dylib
*.so
Cargo.lock
!apps/desktop/src-tauri/Cargo.lock
!src-tauri/Cargo.lock
# local review / sync artifacts
.ui-review-*/
_refs/
_sync/
opsxdb-refs.tar.gz
apps/server/bin/
server/bin/
logs/
apps/desktop/src-tauri/binaries/opsxdb-server-*
src-tauri/binaries/opsxdb-server-*
*.bak
# review artifacts / test outputs
.ui-review/
tests/accept-result.txt
*.tsbuildinfo
# MCP / local build artifacts
/bin/
+25 -8
View File
@@ -1,5 +1,5 @@
> **产品形态**:浏览器 = Gin **官网 + 文档**(`:8788`);**操作只走桌面 Tauri**。
> `apps/web`(`:5174`)仅是桌面 `devUrl`/打包源,**不是**浏览器客户端。
> `src`(`:5174`)仅是桌面 `devUrl`/打包源,**不是**浏览器客户端。
> AI / 协作规范见 [AGENTS.md](./AGENTS.md)(短入口 [CLAUDE.md](./CLAUDE.md))。
# OpsxDB
@@ -9,12 +9,12 @@
## 架构
```
packages/ui 连接选择器 + SQL 工作台 + AI 面板(给桌面打包)
packages/core 共享工具
packages/transport Gin / Tauri 传输
apps/web Vite + React —— **仅桌面前端源**(勿当浏览器产品)
apps/desktop Tauri 2 壳(唯一操作入口)
apps/server Gin:官网/docs + health + 驱动 API(:8788)
src/ React 桌面界面、状态、API 与驱动图标
src-tauri/ Tauri 2 原生壳、能力配置与本机 sidecar
server/ Gin 官网/docs + health + 驱动 API(:8788)
homepage/ 落地页(保持独立)
connection-types/ 数据库引擎注册表
agents/ JDBC agent 桥
```
## 怎么跑
@@ -24,6 +24,8 @@ export PATH="$HOME/.nvm/versions/node/v24.19.0/bin:/opt/homebrew/bin:$PATH"
export OPSXDB_SERVER_PORT=8788 OPSXDB_WEB_PORT=5174
npm install
# npm run dev:Tauri 开发;npm run build:完整桌面包
# npm run build:ui / npm test:前端构建与测试
./run.sh web # 仅官网 + docs → http://127.0.0.1:8788/
./run.sh gui # 桌面操作入口(推荐)
```
@@ -59,4 +61,19 @@ open http://127.0.0.1:8788/docs # 文档
## 待开发
见 [docs/BACKLOG.md](docs/BACKLOG.md)。
见 [docs/BACKLOG.md](docs/BACKLOG.md)。AI/MCP 任务书:[docs/15-AI-MCP-Agent.md](docs/15-AI-MCP-Agent.md)。
## MCP(P0 只读)
外部 Agent(Cursor / Claude Code 等)可通过本机 stdio MCP 安全查库:
```bash
cd server && go build -o ../bin/opsxdb-mcp ./cmd/opsxdb-mcp
```
1. 桌面保存连接后会双写 `~/.opsxdb/connections.json`
2. 配置 `~/.opsxdb/mcp.json`(见 `docs/mcp.json.example`):`allowed_connection_ids` 为空则拒绝全部
3. Cursor 配置见 `docs/mcp-cursor.example.json`
工具层在 `server/internal/aitools`,内部 Agent(P1)将共用同一 runtime。任务书:`docs/15-AI-MCP-Agent.md`。
+193
View File
@@ -0,0 +1,193 @@
# OpsxDB AI / MCP / Agent 任务书
> 更新:2026-10-15 · 用户拍板:**先 MCP,再内部会话,最后 GUI 联动**
> 对标:优先 [DBX MCP](https://github.com/t8y2/dbx)(`packages/mcp-server` / `crates/dbx-mcp`);架构思路参考 oh-my-pi;可选对照 Google MCP Toolbox for Databases、DBHub、官方 MCP SDK。
## 产品结论(已采纳用户补充)
### 2026-10-15 产品 / PM / 设计确认(锁定)
| 项 | 决议 |
|----|------|
| 顺序 | MCP-first → 内部会话共用 Tools → GUI 插入/回写 |
| 首版引擎 | **仅** MySQL 族 + PostgreSQL + Redis;其它库不进 P0 |
| 写操作 | **P0 纯只读**;「确认后才可写」复用现有 dangerConfirm,进 **P1**,MCP 首版不可写 |
| 代码边界 | 只动根级 `src` + `server`;禁止 `packages/ui`(死树) |
| Redis | 对齐现有精确 TYPE / 集群 SCAN,不另起搜键 |
| UI | P0 无新重界面;Ask\|Agent Seg / 步骤时间线 / MCP 状态点属阶段 2–4(见文末设计备忘) |
| 冲突 | 与未推「补偿包」冲突先停报,勿 silently 覆盖 |
| 问题 | 结论 |
|------|------|
| 能不能用 AI 做 MySQL/Redis 等操作? | 能。底座 `/api/db/*` + danger 已有,缺的是工具层。 |
| 借鉴谁? | **优先 DBX**(MCP 工具粒度、只读/可写、连接 scope、审计)。oh-my-pi 借工具调用/会话规范,不当成 DB 产品抄。 |
| 要不要先做 MCP? | **要,优先做 MCP**。内部 AI 会话与 Cursor/Claude/Codex/omp **共用同一套 Tools**,避免两套逻辑。 |
| 形态 | GUI 管连接与会话;MCP 暴露安全的数据操作;Gin 仍是官网/docs,**不是**远程协作后端。 |
## P0 / P1 边界(2026-10-15 收紧)
| 阶段 | 范围 |
|------|------|
| **P0(本轮)** | **纯只读**。`execute_query` 只允许 SELECT/SHOW/EXPLAIN/DESCRIBE/PRAGMA/WITH…SELECT;任何写/DDL **一律拒绝**(**不做** write mode / confirm token)。Redis 仅白名单读命令(GET/SCAN/TYPE/TTL…);FLUSH*/KEYS/写命令拒。MCP stdio + tool runtime + 连接落盘 + 审计。 |
| **P1** | 可写 + 确认令牌/策略;内部 Ask \| Agent UI(设计阶段 2,**先只记文档不实现**);GUI 联动(插入编辑器 / 结果网格);设置页 MCP 白名单 UI。 |
设计阶段 2(内部会话 Ask \| Agent):Agent 调同一 `aitools` runtime;Ask 保持现有生成/解释/优化。**本轮不实现 UI。**
## 落地顺序(锁定)
1. **核心 MCP Tools(最小集)** + 连接落盘可读
2. **安全策略**:默认只读、危险 SQL 拦截、连接白名单、审计日志
3. **内部 AI 会话**:Ask \| Agent;Agent 调同一工具层(进程内调用,不必绕 stdio)
4. **GUI 联动**:SQL 一键插入编辑器;结果可回写网格 / 展示
## P0 工具契约
| Tool | 说明 | 安全默认 |
|------|------|----------|
| `opsxdb_list_connections` | 列出可见连接(id/name/driver/host,无密码) | 受白名单过滤 |
| `opsxdb_list_databases` | 列库 | 连接须在白名单 |
| `opsxdb_list_tables` | 列表/视图(及引擎等价物) | 只读 |
| `opsxdb_describe_table` | 字段与表元数据 | 只读 |
| `opsxdb_get_schema_context` | 紧凑 schema 文本,供模型上下文 | 截断上限(与现 AI schema cap 对齐,约 4k) |
| `opsxdb_execute_query` | 执行 SQL;最多 N 行(建议 100) | **P0 纯只读**(非读语句直接拒绝);可写+确认 = P1 |
| `opsxdb_execute_redis_command` | 执行一条 Redis 命令 | **P0 仅读命令白名单**;FLUSH*/KEYS/写一律拒;P1 再开确认写 |
首版引擎验收:**MySQL 族 + PostgreSQL + Redis(单机)**;其余引擎有连接即可试,不阻塞发版。
## 安全策略(MCP 层统一)
- **P0 强制只读**:`execute_query` 非读类 SQL 一律拒绝;`mcp.json` 的 `mode=read_write` 本轮不生效(P1 才启用写+确认)
- 复用 `server/internal/db/danger.go` 的危险词表;无 WHERE 的 DELETE/UPDATE 拦截
- **连接白名单**(配置文件 `~/.opsxdb/mcp.json`:`allowed_connection_ids`;空=拒绝全部或仅显式 allow——首版建议「空=拒绝」,避免默认放开)
- **行数 / 字节上限**;超时;取消
- **审计**:`~/.opsxdb/logs/mcp-audit.jsonl`(时间、tool、connection_id、只读/写、摘要、错误;不含密码/全文大结果)
- 不暴露:增删连接、原始密码、远程 HTTP 无 token 的开放端口(stdio 默认;本机 HTTP 可选且必须 bearer)
## 连接存储(MCP 可读)
现状:连接在桌面 `localStorage`(`opsxdb.connections.v1`),独立 MCP 进程读不到。
**本轮必做:** GUI `saveConnections` 同步写 `~/.opsxdb/connections.json`(脱敏字段完整、密码仍按现有混淆策略;MCP 与桌面同钥/同解码逻辑,或 MCP 仅经本机已打开的 session——优先**文件同步 + 现有 secrets 解码**,与 DBX「读桌面连接库」同构)。
## 内部会话与 MCP 的关系
```
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Cursor/omp │───▶│ opsxdb-mcp │───▶│ tool runtime │
└─────────────┘ │ (stdio/HTTP) │ │ (db.* API) │
└──────────────────┘ └──────┬──────┘
┌─────────────┐ ┌──────────────────┐ │
│ 侧栏 Agent │───▶│ 同一 tool runtime │◀───────────┘
└─────────────┘ └──────────────────┘
```
侧栏 **不要**再直接拼 prompt 假装执行;Agent 模式只通过 tool runtime。Ask 模式保持现有生成/解释/优化(可不调执行工具)。
## GUI 联动(第 4 步,可薄)
- 工具结果中的 SQL →「插入当前编辑器」
- `execute_query` 结果 → 可选「在结果网格打开」(限行)
- 设置页:MCP 开关说明、白名单、只读/可写、复制 Cursor `mcp.json` 片段
## 刻意首版不做
- MCP 增删连接、Kafka/MQ、桌面 `open_table` 远程遥控(可后置)
- 把 Gin `:8788` 做成公网 Agent 后端
- 两套危险策略(GUI 一套、MCP 一套)
## 验收清单
- [x] `opsxdb-mcp` stdio:`tools/list` 含上表工具(二进制 + 单测注册表)
- [x] 白名单外连接调用失败且有明确错误(空白名单=拒绝全部)
- [x] 只读模式下 `DELETE`/`DROP`/写 SQL 被拒;审计写 `mcp-audit.jsonl`
- [x] 通路代码就绪;单测用 sqlite 覆盖 list/describe/exec;MySQL/PG 真库待桌面验收
- [x] Redis 读白名单 + FLUSH*/KEYS/写拒(单测);真库待验
- [ ] 侧栏 Agent 调同一 runtime(**P1 / 设计阶段 2,本轮不做 UI**)
- [x] 文档:`docs/mcp-cursor.example.json` + 本文件
## 参考路径(本机)
- DBX:`/Users/logan/devops/demo/db-gui/dbx/packages/mcp-server`、`crates/dbx-mcp`
- oh-my-pi:`/Users/logan/devops/demo/ai-agent/oh-my-pi/docs/mcp-*.md`、`docs/custom-tools.md`
- OpsxDB 现有:`server/internal/db/*`、`server/internal/ai/*`、`src/AiPanel.tsx`、`docs/DATABASE-OPERATIONS-AUDIT.md`
## P0 验收方式(命令行优先)
> 产品边界:P0 是 **MCP 工具层**,不是 Web 工作台。验收不走浏览器 `:5174`/官网;Web 只看文档。DeepSeek 侧栏 **Ask 写 SQL** 已可测生成;**Agent 调工具查真库**属阶段 2。
### 环境(本机已有)
| 库 | 端口 | 账号 | 说明 |
|----|------|------|------|
| MySQL 8.4 (`opsxdb-mysql`) | 3306 | root 或 opsx / `Demo@123`,库 `demo` | `tests/dbctl.sh up a` |
| Redis 7 (`opsxdb-redis`) | 6379 | 无密码 | `tests/dbctl.sh up f` |
### A. 单测(无真库也可)
```bash
cd /Users/logan/devops/opsxdb/server
go test ./internal/aitools/ -count=1
```
覆盖:白名单拒绝、只读挡 DROP/DELETE、Redis 危险命令拒绝。
### B. 真库 MCP(推荐主路径)
1. GUI 里保存 MySQL / Redis 连接一次(会双写 `~/.opsxdb/connections.json`),记下 `id`。
2. 写白名单:
```bash
mkdir -p ~/.opsxdb/logs
cat > ~/.opsxdb/mcp.json <<'EOF'
{
"mode": "read_only",
"allowed_connection_ids": ["<mysql-conn-id>", "<redis-conn-id>"]
}
EOF
```
3. 编译并跑:
```bash
cd /Users/logan/devops/opsxdb/server
go build -o ../bin/opsxdb-mcp ./cmd/opsxdb-mcp
# Cursor:mcp.json 指向该二进制;或用临时脚本对 stdio 发 initialize + tools/list + tools/call
```
4. 用例清单:
| # | 工具 | 期望 |
|---|------|------|
| 1 | list_connections | 只见白名单内连接,无密码字段 |
| 2 | list_tables(MySQL demo) | 有表名 |
| 3 | describe_table | 有列 |
| 4 | get_schema_context | 截断文本 |
| 5 | execute_query `SELECT … LIMIT 10` | 有行,≤100 |
| 6 | execute_query `DROP TABLE …` | **拒绝** + 审计 |
| 7 | execute_redis_command `PING` / `GET` | 成功 |
| 8 | execute_redis_command `FLUSHDB` | **拒绝** |
| 9 | 白名单外 connection_id | **拒绝** |
| 10 | 审计文件 | `~/.opsxdb/logs/mcp-audit.jsonl` 有记录 |
### C. DeepSeek(当前能验什么)
- **现在**:设置里选 DeepSeek → Ask「生成/解释」对当前 Tab SQL(不执行工具)。
- **阶段 2**:Agent 模式多轮调同一 MCP tool runtime,再验「让 AI 查 demo 表结构再写 SELECT」。
### D. 验收通过后交付物
在本机 `~/Downloads/` 生成:`AI-Agent接入GUI工具开发实践.md`,供 opsxapi / opsxshell 及其他项目复用(MCP-first、只读默认、连接落盘、Ask|Agent、危险确认)。
## 设计备忘(阶段 2–4,P0 不实现)
来源:设计师 2026-10-15。紧凑桌面、深浅主题;不抄 dbx 整屏工具条。
1. **Ask | Agent**:输入框上方同高同宽小分段;Ask/Agent 不同占位;危险写在执行前确认条(结果区顶或气泡内),Ask 不生成执行条;模式跟连接/Tab,切换不丢草稿。
2. **步骤时间线**:挂在该条 Agent 回复下,可折叠,默认最近 3 步;行内状态点+工具名+一行摘要+耗时;点开才见全文;无独立全高工具面板。
3. **插入/结果**:气泡底「插入当前查询」+「新开查询」;行列结果才给「在结果区打开」;默认不抢焦点。
4. **MCP 状态**:设置 → AI/MCP(开关+白名单);有活跃外部会话时标题旁小点「MCP」,悬停摘要,点开最近调用可断开;无活跃不显示。
落地次序:MCP 开关+白名单 → Ask|Agent Seg → 时间线与插入 → MCP 状态点。
+164 -28
View File
@@ -1,6 +1,6 @@
# OpsxDB 待开发 / 待验证清单
> 维护:项目经理 / 全栈 / 测试补记 · 更新:2026-09-21
> 维护:项目经理 / 全栈 / 测试补记 · 更新:2026-10-14
> 已完成能力见各迭代验收;本文只列**未做完 / 待真实例 / P1 后置**,方便排人。
## 使用说明
@@ -8,7 +8,7 @@
- **P0**:影响主路径或已承诺,优先排
- **P1**:增强体验,可后置
- **环境缺口**:代码/表单已有,缺真实例才能验;有环境再测
- 改代码后:`./run.sh restart`(改 `apps/server` 必须重编)
- 改代码后:`./run.sh restart`(改 `server` 必须重编)
@@ -20,7 +20,7 @@
|----|------|------|
| `./run.sh web` 只起 Gin 官网/docs | **已做** | 浏览器不当客户端 |
| `:5174` 不作产品入口 | **已做** | 仅桌面 Vite 源;`web-only` 弃用提示 |
| `apps/web` / `packages/ui` | **保留** | Tauri `devUrl`/`frontendDist` |
| `src` / `src-tauri` | **保留** | Tauri `devUrl`/`frontendDist` |
| README 写清形态 | **已做** | 浏览器=站;操作=桌面 |
| 验收 | 待测 | `gui` 可用;浏览器无工作台主路径;`:8788` 官网/docs 通 |
@@ -50,7 +50,8 @@
| 键详情(TTL / 重命名 / Hash 增删改 / JSON) | 已交 2026-09-20 | 对标 ARDM + opsxsql |
| CLI 与 SQL 共用编辑器(Redis 按行、命令补全、通用 Do) | 已交 2026-09-20 | 不另开 CLI 页;独立 REPL 列为后置 |
| Sentinel / Cluster **表单 + 后端** | 已交 | — |
| Cluster 真实例测连 + 键浏览 | **本地 3 主拓扑已实测**(2026-09-21:表单建 Cluster 连接、跨分片扫键 19/19 全量、键值/行操作回归) | Sentinel 仍缺真实例 |
| Cluster 真实例测连 + 键浏览 | **本地 3 主拓扑已实测**(2026-09-21:表单建 Cluster 连接、跨分片扫键 19/19 全量、键值/行操作回归);2026-10-15 复验修复后 SET/GET/DEL 跨槽正常(见文末补记) | — |
| Sentinel 真实例 | **已实测**(2026-10-15:docker 起最小拓扑 master+sentinel,FailoverClient 经 sentinel 解析 master,SET/GET/SCAN 键列表/多 DB 0-15 全通;测后容器已清理) | 多 sentinel/主从切换 failover 场景未测 |
| Sentinel / Cluster 文档 | 已交 2026-09-20 | `08-redis.md` / `04-connect.md` |
| 独立 Redis CLI Tab / 监控 / 慢日志 | 后置 | 对标 ARDM CliTab、opsxsql 监控页;非主路径 |
@@ -58,15 +59,52 @@
## 3. 右键菜单
| 项 | 状态 | 待办 |
|----|------|------|
| 编辑器自绘(执行选中/当前语句、剪贴板、拦系统菜单) | 已收 | 回归防回退 |
| 对象树:库 / 表 / Redis 键 P0 | 已收 | — |
| 结果表自绘菜单 | 未做(仅吞系统菜单) | P1:复制单元格/行、导出入口等 |
| 美化 SQL / 含引号复制 / 创建片段 / AI 自动填充 | P1 | 编辑器右键增强 |
| 表:设计结构 / 重命名 / 生成 SQL(CREATE·INSERT)/ 导出 | P1 | — |
| 库:执行 SQL 文件 / 导出转储 / 新建表 | P1 | — |
| Navicat 级:同步、比较、权限、BI、逆向… | 后置 | 明确不做除非立项 |
**2026-10-14 对比优化后状态:主路径已补齐一轮,尚未全部验收。** 设计评分与详细不足见 [GUI 对比报告](GUI-COMPARISON.md)。下表覆盖历史补记中相关「已做」口径;已有工具栏功能不等于已接入右键。标记「已接入」表示代码已落地,仍需在桌面 Tauri 中逐项验收,源码与单测不替代实机及方言验证。
| 编号 | 优先级 / 区域 | 功能 | 当前状态 | 待开发 / 验收要求 |
|------|---------------|------|----------|-------------------|
| 1 | P0 编辑器 | 格式化 / 美化 SQL | 已接入右键,与工具栏复用 | 有选区仅格式化选区,无选区格式化全文;待桌面回归撤销与各方言 |
| 2 | P0 编辑器 | 执行选中 / 当前语句 | 右键已有,共用执行逻辑 | 回归选区、空白处、语句边界,与工具栏行为一致 |
| 3 | P0 编辑器 | 注释 / 取消注释 | 已接入 CodeMirror 注释命令 | 覆盖选区与当前行;非 SQL 驱动禁用;待桌面方言回归 |
| 4 | P0 编辑器 | 复制为纯文本 / 带连接信息 | 已接入,两种复制方式 | 无选区复制全文;连接摘要仅白名单字段,不含用户、密码、SSH 密钥;剪贴板失败不删除选区 |
| 5 | P0 编辑器 | 查找替换 / 跳转到定义 | 搜索替换已接入;明确表名可跳 DDL | 仅解析已加载元数据中的明确标识符,跳过字符串/注释;别名、CTE、未加载对象和复杂解析仍后置 |
| 6 | P0–P1 库 / Schema | 新建查询(带当前库) | 已绑定所点库并持久化 | 普通新查询继承连接默认库;旧标签库不随默认库改变,已有单测;PG Schema 独立上下文见第 10 项 |
| 7 | P0–P1 库 / Schema | 刷新对象 | 已实现 | 回归刷新范围、加载态与失败提示 |
| 8 | P0–P1 库 / Schema | 复制连接串 / 主机端口 | 连接/库菜单已增加无凭据地址与主机端口 | IPv6 已覆盖;完整 JDBC、Oracle SID、Redis Sentinel/Cluster 多节点 DSN 仍需按驱动完善,当前地址不宣称可直接导入全部客户端 |
| 9 | P0–P1 库 / Schema | 断开 / 重连 | 部分:连接节点已有,库节点没有 | 补齐库节点入口或明确归属连接入口;验证状态同步与失败反馈 |
| 10 | P0–P1 库 / Schema | 设为默认库 | 部分:修改保存的连接配置 | 补齐会话语义:MySQL 默认库切换、PG Schema 的 `SET search_path`;验证后续查询与重连,不只显示成功提示 |
| 11 | P1 表 / 视图 | 打开表数据 / 前 N 行 | 已接入前 N 行输入(1–10000) | 通过请求分页传递行数,不向预览 SQL 固化 LIMIT;已加载结果每批显示 500 行,可继续展开;待真库分页与大结果验证 |
| 12 | P1 表 / 视图 | 查看表结构 / DDL | 已有,DDL 按驱动能力控制 | 回归表与视图、目标对象及不支持引擎的禁用状态 |
| 13 | P1 表 / 视图 | 生成 SELECT / INSERT 模板 | 已接入并新开带库查询,避免覆盖草稿 | 视图不提供 INSERT;引用列名与限定名;INSERT 为待填值模板,待真库方言回归 |
| 14 | P1 表 / 视图 | 导出表 CSV / JSON(可后置) | 部分:表右键已接「转储 SQL 文件」三态(2026-10-14,见文末补记);CSV/JSON 整表右键直达仍缺 | 复用导出能力,明确整表与当前结果范围,验证格式与失败提示 |
| 15 | P1 表 / 视图 | 清空表 / 删除表 | 已实现,按能力裁剪并危险确认 | 回归取消不执行、确认后执行及刷新;视图不显示清空表 |
| 16 | P1 表 / 视图 | 复制表名(带库前缀) | 已增加限定名复制 | MySQL 使用库前缀,PG/MSSQL 使用 Schema;标识符转义已有单测,异常元数据命名仍需真库回归 |
| 17 | P1 结果集 | 复制单元格 / 行 / 列(TSV) | 已接入单元格、行号、列头和空白菜单 | 基于实际右键目标与当前排序;TSV 不含表头,保留 NULL/JSON 并引用制表符/换行;待桌面剪贴板验收 |
| 18 | P1 结果集 | 复制为 INSERT / JSON | 已接入当前行 INSERT/JSON 与当前结果 JSON | INSERT 使用结果自身记录的表来源;任意 SQL 无可靠来源则禁用;不猜测全局表名 |
| 19 | P1 结果集 | 导出当前结果 | 右键已接入 CSV / JSON | 复用当前结果导出;只导出当前已加载结果,不冒充整表;待桌面保存文件验证 |
### 第二轮:数据展示、上下文操作、视觉细节
产品原则:借鉴 DBFlux 的布局与上下文层级,借鉴 DBX 的实用功能和回归;保持操作简单,不堆功能。以下为代码已落地、待桌面视觉/交互验收项目。
| 方向 | 已落实 | 验收重点 / 剩余限制 |
|---|---|---|
| 数据展示 | 本地跨列筛选、匹配数、对象路径/列数/耗时;首批 500 行,可继续显示全部已加载行 | 明确本地筛选不查库;已移除固定 2000 行不可继续查看的限制;分批展示不等于虚拟滚动 |
| 值可读性 | NULL、空字符串、空白文本和布尔值区别展示;数值右对齐;长 JSON/文本详情可复制 | 详情只读;大整数文本排序已有精度回归,数据库原始值不改动 |
| 右键实用性 | 单元格:详情/复制;列头:列名/复制列/排序/适应列宽;空白:结果复制/导出 | 按目标隐藏无关操作,不堆一屏禁用按钮;Ctrl/Cmd+C、Shift+F10 和方向键待桌面回归 |
| 视觉层级 | 修复粘性表头被相对定位覆盖;轻量结果工具条、分批加载提示、双击列边界适应内容 | 浅深主题、窄窗、长列名、滚动和数字对齐待实机验收 |
| 连接右键 | 复用统一菜单:连接/断开、重连、编辑、复制、危险操作分组 | 视口约束、Esc、方向键、外点焦点保持;对象树旧菜单仍需后续统一 |
| 状态与正确性 | 列宽/排序按结果集保存,切换时阻止旧状态串写;写回/回滚只更新原结果集;来源不明确禁用 INSERT/写回 | 已新增跨结果更新回归;真实数据库失败与切换时序仍待验证 |
下一轮:完成桌面右键任务验收、PG Schema 默认路径与连接池隔离;之后完善整表导出、驱动专属连接串和真正虚拟滚动。断开/重连归属连接节点,第 9 项的库节点入口暂不重复添加。
本轮验证:前端构建与类型检查通过,54 项测试通过,Go 编译通过;覆盖复制/标识符/库上下文、跨结果集更新、筛选、数值排序与长值展示。新增菜单支持上下方向键、Home/End、Enter、Esc、视口约束与焦点归还。桌面操作工具未获准访问 OpsxDB,视觉、剪贴板与真库验收仍待完成;不据此将评分自动上调。
统一验收:选区与空白右键、菜单视口边界、Esc / 外点关闭、禁用项说明、操作后焦点恢复;涉及执行的操作复用现有危险确认与错误反馈。仅在桌面验收,不把 `:5174` 作为浏览器产品入口。
核查入口:`src/Workspace.tsx` / `src/SqlEditor.tsx`(编辑器)、`src/ObjectTree.tsx` / `src/ConnectionSidebar.tsx`(树与连接)、`src/ResultPane.tsx`(结果集)。
其他后置项:创建片段、AI 自动填充、表重命名、UPDATE/DELETE 模板开放、执行 SQL 文件、索引/外键编辑(设计表 v1 与转储/新建表已于 2026-10-14 落地,见文末补记)。同步、比较、权限、BI、逆向等不在本轮范围,需另行立项。
---
@@ -85,20 +123,25 @@
| 项 | 状态 | 待办 |
|----|------|------|
| 多结果集 / 执行计划 / 书签 / 查询历史 | 已收 | — |
| 多结果集 / 书签 / 查询历史 | 已收 | 执行计划按钮及结果页签按用户要求移除;不再列入近期开发 |
| 查询取消 | 已收 | — |
| 内联编辑 / 多格粘贴 / 行删 / 撤销本格 | 已收 | — |
| 虚拟滚动大数据集 | 部分(限显截断) | P1:真虚拟滚动 |
| 深色/浅色主题切换 | **已做**(2026-09-21 核正:顶栏按钮 + 偏好持久化已有) | — |
| 深色/浅色/随系统主题 | **已做**(2026-10-14 补「随系统」:设置三段选择 + `prefers-color-scheme` 监听,系统切换即时生效,编辑器高亮跟随生效主题) | 桌面切换系统外观实测 |
---
## 6. AI
## 6. AI / MCP / Agent
> 任务书:[15-AI-MCP-Agent.md](15-AI-MCP-Agent.md)(2026-10-15:**MCP-first**,内部会话共用工具层)
| 项 | 状态 | 待办 |
|----|------|------|
| 右侧 AI 面板可开关 | 已收 | — |
| 真模型接入(生成/解释/优化) | stub | 接配置与流式输出 |
| 真模型接入(生成/解释/优化) | **已接**(2026-10-14:Provider 预设 + 三协议,见 §23 补记) | 桌面接真实云端 key 验收;流式输出后置 |
| MCP 最小工具集 + 安全策略(**P0 纯只读**) | **本轮落地** | `aitools` + `opsxdb-mcp` stdio;白名单/审计/connections.json 双写;写+确认=P1;验收盯 MySQL/PG/Redis |
| 内部 Agent 会话(共用 tool runtime) | 未做(P1) | Ask \| Agent UI(设计阶段 2 先只记文档);多轮 + 步骤展示 |
| GUI 联动(插入编辑器 / 结果网格) | 未做(P1) | 设置页 MCP 配置片段 |
---
@@ -166,8 +209,8 @@
| B 层协议冒烟 | 已做;**不摘标** | 真 TiDB/Polar/OB/Gauss/Kingbase/TDSQL 实例到位再验 → 再谈 `verified` |
| Oracle / 达梦 | 环境限 / 未接入 | 2026-09-20 Oracle 容器起了但 ORA-00442;达梦未接入 |
| Redis Sentinel·Cluster 真拓扑 | 表单+字段校验已绿 | 有拓扑:测连 + 键浏览(扫/看/删) |
| 结果表自绘菜单 / 右键 P1 | 未开 | 全栈落地后按产品清单验 |
| 改 `apps/server` 后 | 约定 | 验收前确认 `./run.sh restart`,避免旧二进制误判 |
| 结果表自绘菜单 / 右键 P1 | 代码已接入,待桌面验收 | 按 §3 核对右键目标、排序后复制、NULL/换行、多结果切换与文件保存 |
| 改 `server` 后 | 约定 | 验收前确认 `./run.sh restart`,避免旧二进制误判 |
验收时勿并开多个 `dbctl` 库;结论回写本文件或群对齐即可。
@@ -177,12 +220,12 @@
| 项 | 状态 | 说明 |
|----|------|------|
| SQL 编辑器右键 P0 结构 | 已钉 | 执行选中/当前语句 · 查看数据/DDL · 剪切复制粘贴全选;禁系统菜单 |
| SQL 编辑器右键 P0 结构 | 2026-10-14 已扩充 | 格式化、注释、搜索替换、定义跳转与连接摘要复制;实际边界见 §3 |
| 对象树三套 P0 线框 | 已钉 | 库 / 表 / Redis 键;P1 虚线项见群线框与 §3 |
| Redis Sentinel·Cluster 表单字段 | 已钉 | 同页切部署模式;节点列表 · masterName · 双密码;Cluster 无 DB 索引 |
| 引擎缺图 fallback | 待验 | 灰底首字母,禁用蓝 `?`;未就绪灰 / 未验收橙+悬停 |
| 对象树右键 P1 视觉 | 未开 | 生成 SQL ▸ · 导出 ▸ · 设计表 · 新建表 等,全栈开做时补密度 |
| 结果表自绘菜单 | 未开 | 与全栈 P1 对齐出结构 |
| 结果表自绘菜单 | 已接入,待视觉验收 | 标题标明列目标、操作分组、方向键导航、视口约束与焦点恢复 |
| 顶栏文案 | 已收 | 连接 · 新建连接 · 新建查询 · 查询历史 · 快捷键 |
线框产物(本机):`~/devops/opsxdb/.ui-review/ctx-menus/`。新增设计待办只改本文件。
@@ -202,7 +245,8 @@
| zset Score 毫秒/秒时间戳 → 可读时间副显(`tsLabel`,误判有单测) | 已做 |
| 表格分批渲染:首屏 100 行 + 「加载更多(已显示 X / 共 Y)」 | 已做 |
| 集群跨分片 SCAN(`scanAll` ForEachMaster,本地 3 主 19/19 键实测;编辑器 SCAN 同步修) | 已做 |
| SCAN 游标分页(HSCAN/ZSCAN cursor,>500 大键真翻页) | **未做**(P1) |
| Key 树 SCAN 续扫(单机/Cluster 按主节点保存游标,逐页合并去重) | 已做;真大键空间/集群拓扑变化待桌面复验 |
| Hash/Set/ZSet 值分页(HSCAN/ZSCAN cursor,>500 大键真翻页) | 已有续载实现;真实大键待复验 |
| 批量操作(多选删/按 pattern 批删/导出导入)、监控 INFO/慢日志、更多 viewer(hex/msgpack) | 后置 |
| Sentinel 真拓扑联调 | 环境缺口 |
@@ -213,11 +257,12 @@
| 查询历史:去重置顶、一键重跑 ▶、过滤框、耗时/行数/失败红标(上限 200) | 已做 |
| 点表名打开前 100 行(单击延迟/双击立即,脏 Tab 确认) | 已做 |
| Tab 标题表名化(打开表的新 Tab 显示表名而非「查询 N」) | 已做 |
| 表右键:生成 SELECT/INSERT/UPDATE/DELETE(列感知+方言引号+主键 WHERE)+ 截断/删除表(危险确认) | 已做 |
| 库右键:新建查询 | 已做 |
| 表右键:生成 SELECT/INSERT/UPDATE/DELETE(列感知+方言引号+主键 WHERE)+ 截断/删除表(危险确认) | 历史交付记录;2026-10-14 已补 SELECT/INSERT 菜单,UPDATE/DELETE 模板未开放;截断/删除已接入,当前状态见 §3 |
| 库右键:新建查询 | 2026-10-14 已补所点库上下文与持久化回归,见 §3 |
| 结构页:索引/外键/触发器区块(MySQL 族 information_schema,前端直连) | 已做 |
| 表设计器 / 表重命名 / CREATE TABLE 生成 / 数据导出(CSV/转储) | **未做**(P1) |
| 结果表自绘右键 | **未做**(P1,仍仅吞系统菜单) |
| 表设计器 / 表重命名 / CREATE TABLE 生成 | **未做**(P1);右侧结构查看已有字段及部分引擎的索引/外键/触发器,不等于修改能力 |
| 数据导出(CSV/JSON/INSERT/整表/完整查询) | **已有部分入口**;表右键、流式大表验收、文件与失败处理仍待完善,详见专项盘点 |
| 结果表自绘右键 | 2026-10-14 已补复制与导出,桌面验收待办见 §3 |
### 去 Web 客户端收尾(在 §「去 Web 客户端」基础上)
| 项 | 状态 |
@@ -227,7 +272,7 @@
| CORS 收紧:仅留 5174(桌面 devUrl)与 tauri:// 两个来源 | 已做 |
| 文档/官网口径统一:删「Web 自托管」形态卡与双形态表述;FAQ 明确「浏览器不能操作」 | 已做 |
> 验收口径:以上「已做」项均经 桌面 App 实测(本地 MySQL/Redis 单机 + 3 主 Cluster)或单测覆盖(38 例全绿);测试侧复跑按 §11 流程。
> 历史验收口径:当时「已做」项经桌面 App 实测(本地 MySQL/Redis 单机 + 3 主 Cluster)或单测覆盖(38 例全绿);不代表迁移后的当前状态,右键菜单以 §3 的 2026-10-14 核查及后续桌面复验为准。测试侧复跑按 §11 流程。
## 23. 三项目状态收敛 + opsxdb 设置重构与 DataGrip 高亮(2026-10-14)
@@ -238,4 +283,95 @@
| 「连接已断开」横幅移除 | 已做:整条顶部红色横幅删除(状态行已有提示,顶部横幅不友好);重连能力保留 |
| SQL 高亮 DataGrip 风 | 已做:关键字亮蓝加粗 / 字符串红 / 数字浅绿 / 注释灰斜体 / 标识符浅灰白;light 同构对齐 IntelliJ |
| opsxapi 迁移 | 回滚到可用基线 6452026(apps 布局保留)——用户确认只做 opsxdb;迁移半成品已弃 |
| 修复:GUI 样式全丢(白底裸控件) | 已修:根级迁移漏了 tailwind.config.ts / postcss.config.js(content 路径已改 ./src + ./packages/ui)与 index.html/public 资源;CSS 产物 12KB→32KB(Tailwind 全量恢复)。教训:单包迁移 checklist 应含样式链 |
| 修复:GUI 样式全丢(白底裸控件) | 已修:根级迁移漏了 tailwind.config.ts / postcss.config.js(content 路径已改 ./src)与 index.html/public 资源;CSS 产物 12KB→32KB(Tailwind 全量恢复)。教训:单包迁移 checklist 应含样式链 |
- 2026-10-14:完成根级 Tauri 2 目录整理,移除 apps/packages 重复副本,修复启动、打包、sidecar 路径及图标引用;homepage 未改动。
### 对象树与 dbx 表操作补充(2026-10-14)
- 库下保留表、视图、存储过程、函数四类分组,按驱动能力裁剪;视图现在可展开字段。
- 表下增加索引、外键、触发器分组,展开时才查询;支持独立刷新、复制名称和定义/详情,失败提示重试,未适配引擎明确提示。MySQL 系列、PG 系列、SQLite 已实现目录查询,真实引擎兼容性待验收。元数据最多取 1000 项并提示截断。
- 表右键补齐「查看结构」及「复制建表 / 视图 SQL」,与已有查看数据、前 N 行、DDL、复制名称、SELECT/INSERT 模板、刷新、危险确认操作组成常用菜单。
- 当时对 dbx 截图的后续清单:重命名、表级导入、克隆表;UPDATE/DELETE 模板及表级 CSV/JSON 导出已在后续落地。关系图、数据比较后置;检查约束尚未接入。
- 验证:新增目录查询边界测试;桌面展开/收起、主题、右键定位及真实数据库权限、目录兼容性仍需验收。
### AI 与 Redis 易用性(2026-10-14)
- AI 数据库上下文优先跟随 SQL 标签的当前库;输入区展示连接/库。精简顶部重复选择器和快捷按钮为底部操作选择、多行输入、简短空态。响应仅写入原标签且不覆盖等待期间修改的 SQL。
- Redis Key 搜索框支持回车/搜索按钮;单机各 DB 节点和 Cluster 顶部有独立搜索。输入阶段不请求服务端;无通配符时自动按 `*关键词*` 扫描,完整同名 Key 单独置顶;每批最多 20 次 SCAN(COUNT 200 为提示而非硬限制)、通常返回约 500 项。后续已加入「加载更多」游标续扫;有限扫描无命中不代表 Key 不存在。Cluster 只显示 Key 树,隐藏 DB0 层。
- SQL 工具条可切 Redis 单机 DB0–DB15;切换连接时选择该连接的查询标签,或新建一个标签,AI 库上下文只读取当前连接对应的标签。新建/重选旧标签及命令执行统一校正 Redis 库号,Cluster 固定 DB0 并拒绝 SELECT 切到非零库。AI 操作改为可见选项,删除“方言”。Key 树仅标有效期,不重复标“永久”;键详情可修改 TTL 并保存,0 秒禁止误删。
- 集群拒绝连接增加可操作诊断;本机未发现 7001 监听。需确认用户的种子地址与节点公布地址,不能宣称连接故障已修复。
- 前端构建及 81 项测试通过,Go 编译及 2 项无网络扫描预算回归通过;需监听端口的 Redis 集成测试受沙箱限制,真实集群和桌面视觉仍待验收。
### 工作台标签整理(2026-10-15)
- 启动时无内容的「查询 1」只作为内部编辑器占位,有实际标签时自动移除;表预览命名为「表名@库名」,旧表标签按当前库显示同样格式。标签只显示驱动图标、名称和未保存标记,连接名移至悬停提示;选中态和关闭按钮统一整理。相同表的当前预览可重复刷新,不再生成同名标签;已有 SQL/草稿不会被表预览覆盖。
- 验证:82 项前端测试、前端构建通过;真实桌面窄窗口和多标签滚动仍待视觉验收。
### 表设计器对比与增强(2026-10-15)
- 对照 dbx 结构编辑器和 Navicat 截图:此前弹窗只有字段单页与列级 DDL,宽度不足,缺少普通索引与表注释编辑。借鉴分区标签、紧凑网格、固定预览层级;DBFlux 的代码重点在 Schema 树与浏览,当前未直接复制第三方实现。
- 设计器改为大工作区与字段/索引/外键/触发器/SQL 预览分区。MySQL 系列可编辑普通 BTREE、唯一/复合索引与表注释;特殊索引只读,PG/SQLite 索引及外键/触发器只读。字段排序仅新建表有效。元数据读取失败、存在不支持的变更时禁用应用;实际应用前展示 DDL 确认。
- 修复 MySQL 列 `Extra` 未传递造成自增属性在字段修改时可能丢失的问题。索引 DDL 与转义有单测,真实桌面交互、MySQL/MariaDB 兼容性以及结构漂移校验仍待验收。
### 查询标签的 USE 切库(2026-10-15)
- 普通 SQL 通过当前标签的库发给本机会话,后端按库打开连接池。原先 MySQL/SQL Server 的 `USE` 只修改池中的单条物理连接,标签与下一条语句可能仍指向旧库;现改为验证目标库并更新发起执行的标签,脚本中后续语句读取新库。无效语法或目标库不可用不改变标签,并停止该批次后续语句以免在旧库误执行。PG 改用工具条切库;Redis 单机 `SELECT n`、集群 DB0。
### 库、表、表设计与数据迁移专项盘点
详见 [DATABASE-OPERATIONS-AUDIT.md](DATABASE-OPERATIONS-AUDIT.md)。右侧结构页已复用对象树的 MySQL/PG/SQLite 元数据查询,查询失败单组提示并可重试;AI 晚到 SQL 的原标签/未保存状态和切库保护已有回归。下一步真库核对与树节点定位 → 字段/索引设计及 DDL 预览 → CSV 导入 → 表级导出完善 → 逻辑备份与恢复。规划项未标为已完成。
### 主题随系统 + AI 真模型接入(2026-10-14 · 参考 opsxshell)
| 项 | 状态 |
|----|------|
| 设置→常规主题三段:深色 / 浅色 / 随系统(`prefers-color-scheme` 监听,系统切换即时生效;编辑器高亮跟随生效主题而非原始偏好) | 已做,桌面切换系统外观待实测 |
| AI Provider 预设(DeepSeek / OpenAI / Qwen / Kimi / GLM / MiniMax / Anthropic / Gemini / Ollama / 自定义,选预设自动填 Base URL、协议、默认模型;`src/aiPresets.ts`) | 已做 |
| AI 三协议:openai-chat / openai-responses / anthropic(`x-api-key` + `anthropic-version`),Go 端 `server/internal/ai/remote.go` 按协议分派,旧配置按 provider 推断协议 | 已做 |
| 安全:Base URL 默认仅放行 HTTPS;本地/内网 HTTP 需在设置中显式开启(Ollama 预设默认开启且免鉴权 `authStyle=none`) | 已做 |
| 修复:保存 AI 配置时 key 留空不再静默清空已存 key(服务端合并旧值);LoadConfig 补读 protocol/authStyle/allowInsecureHttp | 已修 |
| 验证:Go ai 包单测(endpoint 校验/三协议解析/协议推断)、前端 aiPresets 测试、64 项前端测试;后端重启后端到端(anthropic 配置回读、HTTP 拦截文案、本机 Ollama 实连返回 404 model not found 证明链路通) | 已过;真实云端 key 桌面验收待做,流式输出后置 |
### 表设计器 v1 + 转储 SQL + ⌘/ 注释(2026-10-14 · 参考 dbx/rustgrid)
| 项 | 状态 |
|----|------|
| 表右键「设计表…」/ 库右键「新建表…」→ 设计器 v1(`src/TableDesigner.tsx`):字段网格(名/类型/长度/NULL/自增/主键/默认/注释)+ 实时 DDL 预览 + 应用 | 已做,真库验收待做 |
| 设计器 DDL 生成(`src/tableDdl.ts`):新建 = 方言化 CREATE(MySQL 族 ENGINE/CHARSET、PG 族双引号);编辑 = 列级 diff ALTER(MySQL ADD/DROP/MODIFY/CHANGE,PG TYPE/NOT NULL/DEFAULT/RENAME,主键组变更 ADD/DROP PRIMARY KEY);SQLite 仅建表+ADD COLUMN,其余注释说明不静默生成 | 已做,11 项单测覆盖 |
| 应用走 execSql,ALTER/CREATE 命中服务端危险词表自然进入二段确认;应用后自动刷新对象树 | 已做 |
| 表右键「转储 SQL 文件 ▸」三态(仅结构 / 仅数据 / 结构和数据),服务端 `POST /api/db/dump` 流式输出 .sql(结构=SHOW CREATE TABLE/拼装 DDL + 注释头;数据=复用 insert 编码;不带 DROP TABLE) | 已做 |
| 快捷键修正:`⌘/`(Mod-/)= 注释/取消注释(CodeMirror toggleComment,对齐 dbx/dbflux/navicat 惯例);帮助面板改 `⌘⇧/`(⌘?);设置与帮助浮层文案同步 | 已做 |
| 验证:前端 75 项测试全绿、tsc 通过、Go build/vet 通过;MySQL 3306 真库端到端(建库建表插数据 → 三种转储模式输出正确、文件名头正确、`库.表` 参数归一) | 已过 |
| 桌面验收清单:设计器新建表→改字段→改主键全流程、转储大表流式与取消、⌘/ 在选中/单行/Redis 命令模式行为 | 待做 |
设计器 v1 后续:普通 MySQL 索引已可编辑,特殊索引和 PG/SQLite 索引仍只读;外键/触发器编辑、引擎/字符集选项、重命名表、克隆表、结构漂移重校验仍待做,详见审计文档。
### 表右键与 Redis 键视图细节(2026-10-15)
| 项 | 状态 |
|----|------|
| 表右键按「生成 SQL / 导出数据 / 转储 SQL」折叠,SQL 提供 SELECT、INSERT、UPDATE、DELETE、DDL 五项;CSV/JSON 可输入最多 500000 行上限,默认 100000;达到上限可能不完整,不能代替备份 | 已做;复合主键和大表导出回归待做 |
| Redis Cluster 在隐藏 DB0 层的键树顶部提供搜索;单机与集群统一「加载更多」;String 默认 Raw、切 JSON 格式化高亮并可编辑、复制值;集群连接地址显示配置的种子节点(例如 127.0.0.1:7101) | 已做;真实多主大键空间及视觉验收待做 |
| CSV 导入向导(预览/列映射/错误行)、结构只读比较、数据比较、导出 XLSX、整库备份/恢复 | 未做;按 `docs/DATABASE-OPERATIONS-AUDIT.md` 的依赖与优先级实施,不加无效按钮 |
### Redis 集群连接失败定位与修复 + 标签确认精简(2026-10-15)
| 项 | 状态 |
|----|------|
| 集群诊断结论:`opsxdb-redis-cluster3`(7101-7103,3 主 16384 槽)健康、宿主机可直连;`opsx-redis-cluster`(:7000)是 `cluster_state:fail` 的孤儿容器(0 槽、无 meet);旧地址 7001-7003 已无监听 | 已定位,**集群无需重启**;建议删除 7000 坏容器避免误连 |
| 修复:`redisMode` 未显式选择但填了多个节点时自动按 Cluster 处理(Sentinel 需显式选+masterName 不受影响);单机客户端收到 MOVED/ASK 时错误文案附「切换部署模式为 Cluster」指引 | 已修,`redis_mode_test.go` 补推断与提示单测 |
| 端到端(127.0.0.1:8790 临时实例):显式 cluster 与省略 mode 两种方式 SET/GET/DEL 跨槽键全部正常(此前同键 MOVED 直抛);单机 6379 PING 回归正常 | 已过 |
| 移除「当前查询未保存…新开标签写入 SELECT?」确认:新开标签不覆盖任何内容,确认无意义;改为 dirty 时直接新开。关闭标签的未保存确认保留(会丢内容) | 已改,前端 75 项测试回归全绿 |
| ⚠ 环境冲突待用户处理:`:8788` 被同族 `opsxapi` 的 `opsxdbapi-server` 占用(2026-10-15 00:06 启动,两项目默认同端口);`./run.sh gui` 探测 health 命中后误以为 opsxdb 后端已运行。需停掉 opsxapi 或为其改端口 | 待用户决策 |
### Redis 键浏览与编辑安全(2026-10-15 · 对照 ARDM / DBFlux / dbx)
| 项 | 状态 |
|----|------|
| 参照 ARDM 的暂停/继续 SCAN、DBFlux 的 Cluster 主节点独立游标:左侧单机各 DB 与 Cluster 键树提供「继续查找 Key」;每页最多 20 次 SCAN、通常约 500 项,跨分片续扫,同名 Key 合并,输入新搜索词重新从头查 | 已实现;SCAN 非快照,扫描期间增删 Key 仍可能重现或遗漏;真实多主集群大键空间待复验 |
| String 详情保存改为 `SET … KEEPTTL`,避免编辑清除原 TTL;Redis 6.0 前遇到不支持该参数时用原子脚本读取 PTTL/写回;并发时旧键消失会报错提示刷新 | 已修,真实 Redis TTL 往返待复验 |
| List 单行删除由 `LREM 1 value` 改为校验索引/原值的原子脚本,避免重复值误删前面一行;按排序/过滤后的真实索引编辑 | 已修;大列表脚本耗时及多人并发需真库复验 |
| 值详情已有 hash/list/set/zset 分页、过滤排序、行编辑及 TTL 设置;当前过滤仅作用于已加载行(不宣称全键搜索) | 已有;真大集合翻页待复验 |
| 借鉴 DBFlux 的可见键数量/内存大小、服务端 `SCAN TYPE` 和 stream 值管理,dbx 的搜索历史/视图状态与分组设置 | P1:先做服务器端类型筛选与大键提示,再评估 stream 编辑;搜索历史和自动滚动按实际使用反馈取舍 |
| CSV/JSON 键级导入导出、批量 UNLINK、离线 RDB 分析、监控/慢日志 | 后置:先验证单键安全与续扫,批量删除不能把 SCAN 结果当作完整集合 |
+9
View File
@@ -0,0 +1,9 @@
{
"mcpServers": {
"opsxdb": {
"command": "/Users/logan/devops/opsxdb/bin/opsxdb-mcp",
"args": [],
"env": {}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"allowed_connection_ids": ["YOUR_CONNECTION_ID"],
"mode": "read_only",
"default_limit": 100,
"max_limit": 500
}
+109
View File
@@ -0,0 +1,109 @@
// opsxdb-mcp:stdio MCP Server,暴露只读数据库工具(P0)。 // Logan.Li
//
// 配置:
// ~/.opsxdb/mcp.json — allowed_connection_ids;mode(P0 执行层强制只读)
// ~/.opsxdb/connections.json — 桌面双写的连接列表(密码 ox1. 混淆)
// ~/.opsxdb/logs/mcp-audit.jsonl
//
// Cursor 示例见 docs/mcp-cursor.example.json
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"opsxdb/server/internal/aitools"
)
func main() {
rt, err := aitools.DefaultRuntime()
if err != nil {
fmt.Fprintf(os.Stderr, "opsxdb-mcp: load policy: %v\n", err)
os.Exit(1)
}
s := server.NewMCPServer(
"opsxdb",
"0.1.0",
server.WithToolCapabilities(true),
)
register := func(name, desc string, props map[string]any, required []string) {
schema := map[string]any{
"type": "object",
"properties": props,
}
if len(required) > 0 {
schema["required"] = required
}
raw, _ := json.Marshal(schema)
t := mcp.NewToolWithRawSchema(name, desc, raw)
toolName := name
s.AddTool(t, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
out, err := rt.Call(ctx, toolName, args)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
return mcp.NewToolResultText(aitools.ResultJSON(out)), nil
})
}
connID := map[string]any{"type": "string", "description": "Saved connection id from opsxdb_list_connections"}
database := map[string]any{"type": "string", "description": "Database / schema / Redis DB index"}
register(aitools.ToolListConnections,
"List whitelisted OpsxDB connections (id/name/driver/host/port/database; never passwords).",
map[string]any{}, nil)
register(aitools.ToolListDatabases,
"List databases for a connection.",
map[string]any{"connection_id": connID}, []string{"connection_id"})
register(aitools.ToolListTables,
"List tables/views (or engine equivalents) in a database.",
map[string]any{"connection_id": connID, "database": database}, []string{"connection_id"})
register(aitools.ToolDescribeTable,
"Describe columns and metadata for a table.",
map[string]any{
"connection_id": connID,
"database": database,
"table": map[string]any{"type": "string", "description": "Table name"},
}, []string{"connection_id", "table"})
register(aitools.ToolGetSchemaContext,
"Compact schema text for LLM context (capped ~4k chars).",
map[string]any{
"connection_id": connID,
"database": database,
"max_tables": map[string]any{"type": "integer", "description": "Max tables to include (default 40)"},
}, []string{"connection_id"})
register(aitools.ToolExecuteQuery,
"Execute a READ-ONLY SQL query (SELECT/SHOW/EXPLAIN/WITH…SELECT). Writes and DDL are rejected in P0. Default limit 100, max 500.",
map[string]any{
"connection_id": connID,
"sql": map[string]any{"type": "string", "description": "Read-only SQL"},
"database": database,
"limit": map[string]any{"type": "integer", "description": "Row limit (default 100, max 500)"},
}, []string{"connection_id", "sql"})
register(aitools.ToolExecuteRedisCommand,
"Execute a READ-ONLY Redis command (GET/SCAN/TYPE/TTL/…). FLUSH*/KEYS and writes are rejected in P0.",
map[string]any{
"connection_id": connID,
"command": map[string]any{"type": "string", "description": "Single Redis command line"},
"database": database,
}, []string{"connection_id", "command"})
if err := server.ServeStdio(s); err != nil {
fmt.Fprintf(os.Stderr, "opsxdb-mcp: %v\n", err)
os.Exit(1)
}
}
+9 -2
View File
@@ -3,9 +3,11 @@ module opsxdb/server
go 1.25.0
require (
github.com/alicebob/miniredis/v2 v2.39.0
github.com/gin-gonic/gin v1.10.0
github.com/go-sql-driver/mysql v1.8.1
github.com/jackc/pgx/v5 v5.7.2
github.com/mark3labs/mcp-go v0.43.2
github.com/microsoft/go-mssqldb v1.8.0
github.com/redis/go-redis/v9 v9.7.0
github.com/sijms/go-ora/v2 v2.8.22
@@ -16,7 +18,8 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/alicebob/miniredis/v2 v2.39.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
@@ -33,13 +36,14 @@ require (
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
@@ -47,9 +51,12 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.51.0 // indirect
+19 -3
View File
@@ -14,10 +14,14 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mx
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/alicebob/miniredis/v2 v2.39.0 h1:M7WbmV5BmV56L8KTG0rw6vEQ+woTOghpDgin2xv4A0g=
github.com/alicebob/miniredis/v2 v2.39.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
@@ -28,7 +32,6 @@ github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -36,6 +39,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
@@ -67,6 +72,8 @@ github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlG
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -75,6 +82,7 @@ github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -87,6 +95,10 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/microsoft/go-mssqldb v1.8.0 h1:7cyZ/AT7ycDsEoWPIXibd+aVKFtteUNhDGf3aobP+tw=
@@ -102,18 +114,18 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sijms/go-ora/v2 v2.8.22 h1:3ABgRzVKxS439cEgSLjFKutIwOyhnyi4oOSBywEdOlU=
github.com/sijms/go-ora/v2 v2.8.22/go.mod h1:QgFInVi3ZWyqAiJwzBQA+nbKYKH77tdp1PYoCqhR2dU=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -130,6 +142,10 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
+56
View File
@@ -0,0 +1,56 @@
package aitools
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
)
// AuditEntry MCP/Agent 工具审计;不含密码与大结果全文。 // Logan.Li
type AuditEntry struct {
Time string `json:"time"`
Tool string `json:"tool"`
ConnectionID string `json:"connection_id,omitempty"`
Mode string `json:"mode"`
OK bool `json:"ok"`
Summary string `json:"summary,omitempty"`
Error string `json:"error,omitempty"`
}
var auditMu sync.Mutex
func AuditPath() (string, error) {
dir, err := opsxdbDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "logs", "mcp-audit.jsonl"), nil
}
// AppendAudit 追加一行 JSONL;失败静默(不影响工具主路径)。
func AppendAudit(e AuditEntry) {
if e.Time == "" {
e.Time = time.Now().Format(time.RFC3339)
}
path, err := AuditPath()
if err != nil {
return
}
auditMu.Lock()
defer auditMu.Unlock()
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return
}
b, err := json.Marshal(e)
if err != nil {
return
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return
}
defer f.Close()
_, _ = f.Write(append(b, '\n'))
}
+167
View File
@@ -0,0 +1,167 @@
package aitools
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"opsxdb/server/internal/db"
)
// SavedConnection 与桌面 store SavedConnection JSON 对齐(磁盘形态,密码可为 ox1. 混淆)。 // Logan.Li
type SavedConnection struct {
ID string `json:"id"`
Name string `json:"name"`
Driver string `json:"driver"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
Database string `json:"database"`
Service string `json:"service,omitempty"`
SSL bool `json:"ssl,omitempty"`
SSLMode string `json:"sslMode,omitempty"`
SSLCA string `json:"sslCA,omitempty"`
SSLCert string `json:"sslCert,omitempty"`
SSLKey string `json:"sslKey,omitempty"`
RedisMode string `json:"redisMode,omitempty"`
RedisNodes string `json:"redisNodes,omitempty"`
MasterName string `json:"masterName,omitempty"`
SentinelPassword string `json:"sentinelPassword,omitempty"`
SSHEnabled bool `json:"sshEnabled,omitempty"`
SSHHost string `json:"sshHost,omitempty"`
SSHPort int `json:"sshPort,omitempty"`
SSHUser string `json:"sshUser,omitempty"`
SSHAuth string `json:"sshAuth,omitempty"`
SSHPassword string `json:"sshPassword,omitempty"`
SSHPrivateKey string `json:"sshPrivateKey,omitempty"`
SSHPassphrase string `json:"sshPassphrase,omitempty"`
SavePassword *bool `json:"savePassword,omitempty"`
}
// ConnectionSummary 列表对外字段,永不含密码。
type ConnectionSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Driver string `json:"driver"`
Host string `json:"host"`
Port int `json:"port"`
Database string `json:"database"`
}
func ConnectionsPath() (string, error) {
dir, err := opsxdbDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "connections.json"), nil
}
// LoadConnections 读 ~/.opsxdb/connections.json。
func LoadConnections() ([]SavedConnection, error) {
path, err := ConnectionsPath()
if err != nil {
return nil, err
}
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var list []SavedConnection
if err := json.Unmarshal(b, &list); err != nil {
return nil, fmt.Errorf("connections.json: %w", err)
}
return list, nil
}
// SaveConnections 写 ~/.opsxdb/connections.json(0600);供桌面双写 API 调用。
func SaveConnections(list []SavedConnection) error {
path, err := ConnectionsPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
b, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, b, 0600)
}
func FindConnection(id string) (*SavedConnection, error) {
list, err := LoadConnections()
if err != nil {
return nil, err
}
for i := range list {
if list[i].ID == id {
c := list[i]
return &c, nil
}
}
return nil, fmt.Errorf("connection not found: %s", id)
}
// ToConnReq 映射为 db.ConnReq(解码 ox1. 密码)。
func (c SavedConnection) ToConnReq() db.ConnReq {
sslOn := c.SSL || (c.SSLMode != "" && c.SSLMode != "disable")
mode := c.SSLMode
if sslOn {
if mode == "" || mode == "disable" {
mode = "require"
}
} else {
mode = "disable"
}
return db.ConnReq{
Driver: c.Driver,
Host: c.Host,
Port: c.Port,
User: c.User,
Password: DecodeSecret(c.Password),
Database: c.Database,
Service: c.Service,
SSL: sslOn,
SSLMode: mode,
SSLCA: c.SSLCA,
SSLCert: c.SSLCert,
SSLKey: c.SSLKey,
RedisMode: orDefault(c.RedisMode, "standalone"),
RedisNodes: c.RedisNodes,
MasterName: c.MasterName,
SentinelPassword: DecodeSecret(c.SentinelPassword),
SSHEnabled: c.SSHEnabled,
SSHHost: c.SSHHost,
SSHPort: c.SSHPort,
SSHUser: c.SSHUser,
SSHAuth: c.SSHAuth,
SSHPassword: DecodeSecret(c.SSHPassword),
SSHPrivateKey: c.SSHPrivateKey,
SSHPassphrase: DecodeSecret(c.SSHPassphrase),
}
}
func (c SavedConnection) Summary() ConnectionSummary {
return ConnectionSummary{
ID: c.ID,
Name: c.Name,
Driver: c.Driver,
Host: c.Host,
Port: c.Port,
Database: c.Database,
}
}
func orDefault(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}
+112
View File
@@ -0,0 +1,112 @@
package aitools
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// Mode P0 仅支持 read_only;read_write 留给 P1(写 + 确认)。 // Logan.Li
const (
ModeReadOnly = "read_only"
ModeReadWrite = "read_write" // P1 才启用写路径;P0 加载后仍强制只读执行
)
// Policy ~/.opsxdb/mcp.json
type Policy struct {
AllowedConnectionIDs []string `json:"allowed_connection_ids"`
Mode string `json:"mode"` // read_only | read_write
DefaultLimit int `json:"default_limit,omitempty"`
MaxLimit int `json:"max_limit,omitempty"`
}
func DefaultPolicy() Policy {
return Policy{
AllowedConnectionIDs: nil,
Mode: ModeReadOnly,
DefaultLimit: 100,
MaxLimit: 500,
}
}
func opsxdbDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".opsxdb"), nil
}
func PolicyPath() (string, error) {
dir, err := opsxdbDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "mcp.json"), nil
}
// LoadPolicy 读 mcp.json;缺省文件返回 DefaultPolicy(白名单空=拒绝全部连接)。
func LoadPolicy() (Policy, error) {
p := DefaultPolicy()
path, err := PolicyPath()
if err != nil {
return p, err
}
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return p, nil
}
return p, err
}
if err := json.Unmarshal(b, &p); err != nil {
return DefaultPolicy(), fmt.Errorf("mcp.json: %w", err)
}
p.Mode = strings.ToLower(strings.TrimSpace(p.Mode))
if p.Mode == "" {
p.Mode = ModeReadOnly
}
if p.DefaultLimit <= 0 {
p.DefaultLimit = 100
}
if p.MaxLimit <= 0 {
p.MaxLimit = 500
}
if p.DefaultLimit > p.MaxLimit {
p.DefaultLimit = p.MaxLimit
}
return p, nil
}
func (p Policy) IsReadOnly() bool {
// P0:即使配置写了 read_write,执行层仍按只读拦截;此 helper 表示「策略意图」。
return p.Mode != ModeReadWrite
}
func (p Policy) AllowConnection(id string) error {
id = strings.TrimSpace(id)
if id == "" {
return fmt.Errorf("connection_id required")
}
if len(p.AllowedConnectionIDs) == 0 {
return fmt.Errorf("connection not allowed: whitelist empty (configure allowed_connection_ids in ~/.opsxdb/mcp.json)")
}
for _, a := range p.AllowedConnectionIDs {
if a == id {
return nil
}
}
return fmt.Errorf("connection not allowed: %s not in allowed_connection_ids", id)
}
func (p Policy) ClampLimit(n int) int {
if n <= 0 {
n = p.DefaultLimit
}
if n > p.MaxLimit {
n = p.MaxLimit
}
return n
}
+119
View File
@@ -0,0 +1,119 @@
package aitools
import (
"fmt"
"strings"
"unicode"
"opsxdb/server/internal/db"
)
// redisReadCmds P0 纯只读允许的 Redis 命令(对齐现有 execRedis 读路径)。 // Logan.Li
var redisReadCmds = map[string]bool{
"PING": true, "GET": true, "MGET": true, "TYPE": true, "TTL": true, "PTTL": true,
"EXISTS": true, "SCAN": true, "HGET": true, "HGETALL": true, "HLEN": true, "HKEYS": true, "HVALS": true,
"LRANGE": true, "LLEN": true, "LINDEX": true,
"SMEMBERS": true, "SCARD": true, "SISMEMBER": true,
"ZRANGE": true, "ZCARD": true, "ZSCORE": true, "ZRANK": true,
"STRLEN": true, "DBSIZE": true, "INFO": true, "OBJECT": true, "MEMORY": true,
}
// redisDeniedAlways P0 明确拒绝(即便将来 read_write 也需确认;此处直接拒)。
var redisDeniedAlways = map[string]bool{
"FLUSHALL": true, "FLUSHDB": true, "KEYS": true, "CONFIG": true,
}
// IsReadOnlySQL P0:仅允许读类 SQL。INSERT/UPDATE/DELETE/DDL 一律 false。
// WITH 仅当体中无写动词且含 SELECT 时放行。复用 CheckDangerousSQL 作二次拦截。
func IsReadOnlySQL(sqlText string) (bool, string) {
s := strings.TrimSpace(stripLeadComments(sqlText))
if s == "" {
return false, "empty sql"
}
up := strings.ToUpper(s)
// 去掉开头空白后的首词
first := firstWord(up)
switch first {
case "SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "PRAGMA":
// ok
case "WITH":
// WITH … SELECT 可读;WITH … INSERT/UPDATE/DELETE 拒
if hasWriteVerb(up) {
return false, "WITH statement contains write verb (P0 read-only)"
}
if !strings.Contains(up, "SELECT") {
return false, "WITH without SELECT is not allowed in read-only mode"
}
default:
return false, fmt.Sprintf("not a read-only statement (starts with %s); P0 MCP is read-only", first)
}
if hits := db.CheckDangerousSQL("", sqlText); len(hits) > 0 {
return false, "dangerous sql blocked: " + strings.Join(hits, "; ")
}
return true, ""
}
func hasWriteVerb(up string) bool {
for _, v := range []string{" INSERT ", " UPDATE ", " DELETE ", " DROP ", " ALTER ", " CREATE ", " TRUNCATE ", " REPLACE ", " MERGE ", " GRANT ", " REVOKE ", " CALL ", " EXEC ", " EXECUTE "} {
if strings.Contains(" "+up+" ", v) {
return true
}
}
// 行首写动词
for _, v := range []string{"INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE", "REPLACE", "MERGE"} {
if strings.HasPrefix(up, v+" ") || up == v {
return true
}
}
return false
}
func firstWord(up string) string {
up = strings.TrimSpace(up)
i := 0
for i < len(up) && (unicode.IsLetter(rune(up[i])) || up[i] == '_') {
i++
}
if i == 0 {
return ""
}
return up[:i]
}
func stripLeadComments(sql string) string {
s := strings.TrimSpace(sql)
for {
if strings.HasPrefix(s, "--") {
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = strings.TrimSpace(s[i+1:])
continue
}
return ""
}
if strings.HasPrefix(s, "/*") {
if i := strings.Index(s, "*/"); i >= 0 {
s = strings.TrimSpace(s[i+2:])
continue
}
return ""
}
break
}
return s
}
// CheckRedisReadOnly P0:仅白名单读命令;FLUSH*/KEYS/CONFIG 等一律拒。
func CheckRedisReadOnly(commandLine string) (cmd string, err error) {
fields := strings.Fields(strings.TrimSpace(commandLine))
if len(fields) == 0 {
return "", fmt.Errorf("empty redis command")
}
cmd = strings.ToUpper(fields[0])
if redisDeniedAlways[cmd] {
return cmd, fmt.Errorf("redis command denied in P0 read-only: %s", cmd)
}
if !redisReadCmds[cmd] {
return cmd, fmt.Errorf("redis command not allowed in P0 read-only: %s (writes require P1)", cmd)
}
return cmd, nil
}
+466
View File
@@ -0,0 +1,466 @@
package aitools
import (
"context"
"encoding/json"
"fmt"
"strings"
"opsxdb/server/internal/db"
)
const (
ToolListConnections = "opsxdb_list_connections"
ToolListDatabases = "opsxdb_list_databases"
ToolListTables = "opsxdb_list_tables"
ToolDescribeTable = "opsxdb_describe_table"
ToolGetSchemaContext = "opsxdb_get_schema_context"
ToolExecuteQuery = "opsxdb_execute_query"
ToolExecuteRedisCommand = "opsxdb_execute_redis_command"
schemaContextCap = 4000
)
// ToolNames P0 注册表(顺序稳定,便于 tools/list 断言)。
func ToolNames() []string {
return []string{
ToolListConnections,
ToolListDatabases,
ToolListTables,
ToolDescribeTable,
ToolGetSchemaContext,
ToolExecuteQuery,
ToolExecuteRedisCommand,
}
}
// Runtime 可被 MCP stdio 与将来侧栏 Agent 进程内共用。 // Logan.Li
type Runtime struct {
Policy Policy
// LoadConns / FindConn 可注入,便于单测;nil 则读磁盘。
LoadConns func() ([]SavedConnection, error)
FindConn func(id string) (*SavedConnection, error)
// ExecSQL 可注入;nil 则 db.ExecSQL。
ExecSQL func(ctx context.Context, c db.ConnReq) db.ExecResult
ListDB func(ctx context.Context, c db.ConnReq) ([]string, error)
ListTbl func(ctx context.Context, c db.ConnReq) ([]string, error)
ListCol func(ctx context.Context, c db.ConnReq) ([]db.ColumnInfo, error)
Audit func(AuditEntry)
}
func NewRuntime(policy Policy) *Runtime {
return &Runtime{Policy: policy}
}
func DefaultRuntime() (*Runtime, error) {
p, err := LoadPolicy()
if err != nil {
return nil, err
}
return NewRuntime(p), nil
}
func (r *Runtime) audit(e AuditEntry) {
if e.Mode == "" {
e.Mode = r.Policy.Mode
if e.Mode == "" {
e.Mode = ModeReadOnly
}
}
if r.Audit != nil {
r.Audit(e)
return
}
AppendAudit(e)
}
func (r *Runtime) loadConns() ([]SavedConnection, error) {
if r.LoadConns != nil {
return r.LoadConns()
}
return LoadConnections()
}
func (r *Runtime) findConn(id string) (*SavedConnection, error) {
if r.FindConn != nil {
return r.FindConn(id)
}
return FindConnection(id)
}
func (r *Runtime) resolveAllowed(connectionID string) (*SavedConnection, error) {
if err := r.Policy.AllowConnection(connectionID); err != nil {
return nil, err
}
c, err := r.findConn(connectionID)
if err != nil {
return nil, err
}
return c, nil
}
func (r *Runtime) execSQL(ctx context.Context, c db.ConnReq) db.ExecResult {
if r.ExecSQL != nil {
return r.ExecSQL(ctx, c)
}
return db.ExecSQL(ctx, c)
}
func (r *Runtime) listDB(ctx context.Context, c db.ConnReq) ([]string, error) {
if r.ListDB != nil {
return r.ListDB(ctx, c)
}
return db.ListDatabases(ctx, c)
}
func (r *Runtime) listTbl(ctx context.Context, c db.ConnReq) ([]string, error) {
if r.ListTbl != nil {
return r.ListTbl(ctx, c)
}
return db.ListTables(ctx, c)
}
func (r *Runtime) listCol(ctx context.Context, c db.ConnReq) ([]db.ColumnInfo, error) {
if r.ListCol != nil {
return r.ListCol(ctx, c)
}
return db.ListColumns(ctx, c)
}
// Call 统一入口:MCP 与 Agent 都走这里。args 为 JSON object。
func (r *Runtime) Call(ctx context.Context, tool string, args map[string]any) (any, error) {
switch tool {
case ToolListConnections:
return r.listConnections(ctx)
case ToolListDatabases:
return r.listDatabases(ctx, args)
case ToolListTables:
return r.listTables(ctx, args)
case ToolDescribeTable:
return r.describeTable(ctx, args)
case ToolGetSchemaContext:
return r.getSchemaContext(ctx, args)
case ToolExecuteQuery:
return r.executeQuery(ctx, args)
case ToolExecuteRedisCommand:
return r.executeRedis(ctx, args)
default:
return nil, fmt.Errorf("unknown tool: %s", tool)
}
}
func (r *Runtime) listConnections(ctx context.Context) (any, error) {
_ = ctx
list, err := r.loadConns()
if err != nil {
r.audit(AuditEntry{Tool: ToolListConnections, OK: false, Error: err.Error()})
return nil, err
}
out := make([]ConnectionSummary, 0, len(list))
for _, c := range list {
if err := r.Policy.AllowConnection(c.ID); err != nil {
continue
}
out = append(out, c.Summary())
}
r.audit(AuditEntry{Tool: ToolListConnections, OK: true, Summary: fmt.Sprintf("%d connections", len(out))})
return map[string]any{"connections": out}, nil
}
func argString(args map[string]any, key string) string {
if args == nil {
return ""
}
v, ok := args[key]
if !ok || v == nil {
return ""
}
switch t := v.(type) {
case string:
return strings.TrimSpace(t)
default:
return strings.TrimSpace(fmt.Sprint(t))
}
}
func argInt(args map[string]any, key string, def int) int {
if args == nil {
return def
}
v, ok := args[key]
if !ok || v == nil {
return def
}
switch t := v.(type) {
case float64:
return int(t)
case int:
return t
case int64:
return int(t)
case json.Number:
n, _ := t.Int64()
return int(n)
case string:
var n int
fmt.Sscanf(t, "%d", &n)
if n == 0 {
return def
}
return n
default:
return def
}
}
func (r *Runtime) listDatabases(ctx context.Context, args map[string]any) (any, error) {
id := argString(args, "connection_id")
c, err := r.resolveAllowed(id)
if err != nil {
r.audit(AuditEntry{Tool: ToolListDatabases, ConnectionID: id, OK: false, Error: err.Error()})
return nil, err
}
req := c.ToConnReq()
dbs, err := r.listDB(ctx, req)
if err != nil {
r.audit(AuditEntry{Tool: ToolListDatabases, ConnectionID: id, OK: false, Error: err.Error()})
return nil, err
}
r.audit(AuditEntry{Tool: ToolListDatabases, ConnectionID: id, OK: true, Summary: fmt.Sprintf("%d databases", len(dbs))})
return map[string]any{"databases": dbs}, nil
}
func (r *Runtime) listTables(ctx context.Context, args map[string]any) (any, error) {
id := argString(args, "connection_id")
database := argString(args, "database")
c, err := r.resolveAllowed(id)
if err != nil {
r.audit(AuditEntry{Tool: ToolListTables, ConnectionID: id, OK: false, Error: err.Error()})
return nil, err
}
req := c.ToConnReq()
if database != "" {
req.Database = database
}
tables, err := r.listTbl(ctx, req)
if err != nil {
r.audit(AuditEntry{Tool: ToolListTables, ConnectionID: id, OK: false, Error: err.Error()})
return nil, err
}
r.audit(AuditEntry{Tool: ToolListTables, ConnectionID: id, OK: true, Summary: fmt.Sprintf("%d tables", len(tables))})
return map[string]any{"tables": tables, "database": req.Database}, nil
}
func (r *Runtime) describeTable(ctx context.Context, args map[string]any) (any, error) {
id := argString(args, "connection_id")
database := argString(args, "database")
table := argString(args, "table")
if table == "" {
err := fmt.Errorf("table required")
r.audit(AuditEntry{Tool: ToolDescribeTable, ConnectionID: id, OK: false, Error: err.Error()})
return nil, err
}
c, err := r.resolveAllowed(id)
if err != nil {
r.audit(AuditEntry{Tool: ToolDescribeTable, ConnectionID: id, OK: false, Error: err.Error()})
return nil, err
}
req := c.ToConnReq()
if database != "" {
req.Database = database
}
req.Table = table
cols, err := r.listCol(ctx, req)
if err != nil {
r.audit(AuditEntry{Tool: ToolDescribeTable, ConnectionID: id, OK: false, Error: err.Error()})
return nil, err
}
r.audit(AuditEntry{Tool: ToolDescribeTable, ConnectionID: id, OK: true, Summary: table + fmt.Sprintf(" (%d cols)", len(cols))})
return map[string]any{"table": table, "database": req.Database, "columns": cols}, nil
}
func (r *Runtime) getSchemaContext(ctx context.Context, args map[string]any) (any, error) {
id := argString(args, "connection_id")
database := argString(args, "database")
maxTables := argInt(args, "max_tables", 40)
if maxTables <= 0 {
maxTables = 40
}
if maxTables > 200 {
maxTables = 200
}
c, err := r.resolveAllowed(id)
if err != nil {
r.audit(AuditEntry{Tool: ToolGetSchemaContext, ConnectionID: id, OK: false, Error: err.Error()})
return nil, err
}
req := c.ToConnReq()
if database != "" {
req.Database = database
}
tables, err := r.listTbl(ctx, req)
if err != nil {
r.audit(AuditEntry{Tool: ToolGetSchemaContext, ConnectionID: id, OK: false, Error: err.Error()})
return nil, err
}
var b strings.Builder
n := 0
for _, t := range tables {
if n >= maxTables {
break
}
colReq := req
colReq.Table = t
cols, err := r.listCol(ctx, colReq)
if err != nil {
continue
}
parts := make([]string, 0, len(cols))
for _, col := range cols {
parts = append(parts, strings.TrimSpace(col.Name+" "+col.Type))
}
dbName := req.Database
if dbName == "" {
dbName = "default"
}
line := fmt.Sprintf("%s.%s(%s)\n", dbName, t, strings.Join(parts, ", "))
if b.Len()+len(line) > schemaContextCap {
break
}
b.WriteString(line)
n++
}
text := b.String()
if len(text) > schemaContextCap {
text = text[:schemaContextCap]
}
r.audit(AuditEntry{Tool: ToolGetSchemaContext, ConnectionID: id, OK: true, Summary: fmt.Sprintf("%d tables, %d chars", n, len(text))})
return map[string]any{"schema": text, "truncated": len(tables) > n || len(text) >= schemaContextCap}, nil
}
func (r *Runtime) executeQuery(ctx context.Context, args map[string]any) (any, error) {
id := argString(args, "connection_id")
sqlText := argString(args, "sql")
database := argString(args, "database")
limit := r.Policy.ClampLimit(argInt(args, "limit", 0))
fail := func(msg string) (any, error) {
r.audit(AuditEntry{Tool: ToolExecuteQuery, ConnectionID: id, OK: false, Error: msg, Summary: truncate(sqlText, 80)})
return nil, fmt.Errorf("%s", msg)
}
if sqlText == "" {
return fail("sql required")
}
// P0 纯只读:写/DDL 一律拒绝;不做 write mode / confirm token。
if ok, reason := IsReadOnlySQL(sqlText); !ok {
return fail(reason)
}
c, err := r.resolveAllowed(id)
if err != nil {
return fail(err.Error())
}
if strings.EqualFold(c.Driver, "redis") {
return fail("redis connections do not accept SQL; use opsxdb_execute_redis_command")
}
req := c.ToConnReq()
if database != "" {
req.Database = database
}
req.SQL = sqlText
req.Limit = limit
req.ConfirmDanger = false // 永不带确认放行
res := r.execSQL(ctx, req)
if res.NeedConfirm {
// 只读层已拦;若底层仍标 needConfirm,视为拒绝
return fail("dangerous sql blocked (needConfirm): " + res.Error)
}
if res.Error != "" {
return fail(res.Error)
}
r.audit(AuditEntry{
Tool: ToolExecuteQuery, ConnectionID: id, OK: true,
Summary: fmt.Sprintf("rows=%d truncated=%v sql=%s", res.RowCount, res.Truncated, truncate(sqlText, 60)),
})
return map[string]any{
"columns": res.Columns,
"rows": res.Rows,
"rowCount": res.RowCount,
"timeMs": res.TimeMs,
"truncated": res.Truncated,
"limit": limit,
}, nil
}
func (r *Runtime) executeRedis(ctx context.Context, args map[string]any) (any, error) {
id := argString(args, "connection_id")
command := argString(args, "command")
database := argString(args, "database")
fail := func(msg string) (any, error) {
r.audit(AuditEntry{Tool: ToolExecuteRedisCommand, ConnectionID: id, OK: false, Error: msg, Summary: truncate(command, 80)})
return nil, fmt.Errorf("%s", msg)
}
if command == "" {
return fail("command required")
}
cmd, err := CheckRedisReadOnly(command)
if err != nil {
return fail(err.Error())
}
_ = cmd
c, err := r.resolveAllowed(id)
if err != nil {
return fail(err.Error())
}
if !strings.EqualFold(c.Driver, "redis") {
return fail("connection is not redis")
}
req := c.ToConnReq()
if database != "" {
req.Database = database
}
req.SQL = command
req.ConfirmDanger = false
res := r.execSQL(ctx, req)
if res.NeedConfirm {
return fail("dangerous redis command blocked: " + res.Error)
}
if res.Error != "" {
return fail(res.Error)
}
r.audit(AuditEntry{
Tool: ToolExecuteRedisCommand, ConnectionID: id, OK: true,
Summary: fmt.Sprintf("cmd=%s rows=%d", cmd, res.RowCount),
})
return map[string]any{
"columns": res.Columns,
"rows": res.Rows,
"rowCount": res.RowCount,
"timeMs": res.TimeMs,
"truncated": res.Truncated,
}, nil
}
func truncate(s string, n int) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// ResultJSON 将 Call 结果序列化为 MCP 文本内容。
func ResultJSON(v any) string {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}
+229
View File
@@ -0,0 +1,229 @@
package aitools
import (
"context"
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
"opsxdb/server/internal/db"
)
func TestIsReadOnlySQL(t *testing.T) {
okCases := []string{
"SELECT 1",
"SHOW TABLES",
"EXPLAIN SELECT * FROM t",
"WITH c AS (SELECT 1 AS x) SELECT * FROM c",
"PRAGMA table_info(t)",
"DESCRIBE t",
}
for _, sql := range okCases {
if ok, reason := IsReadOnlySQL(sql); !ok {
t.Fatalf("expected read-only ok for %q: %s", sql, reason)
}
}
bad := []string{
"DELETE FROM t",
"DELETE FROM t WHERE id=1",
"DROP TABLE t",
"INSERT INTO t VALUES (1)",
"UPDATE t SET a=1",
"CREATE TABLE t (id INT)",
"ALTER TABLE t ADD c INT",
"TRUNCATE TABLE t",
"WITH c AS (SELECT 1) INSERT INTO t SELECT * FROM c",
}
for _, sql := range bad {
if ok, _ := IsReadOnlySQL(sql); ok {
t.Fatalf("expected reject for %q", sql)
}
}
}
func TestCheckRedisReadOnly(t *testing.T) {
if _, err := CheckRedisReadOnly("GET foo"); err != nil {
t.Fatal(err)
}
if _, err := CheckRedisReadOnly("TYPE foo"); err != nil {
t.Fatal(err)
}
if _, err := CheckRedisReadOnly("SCAN 0 MATCH * COUNT 10"); err != nil {
t.Fatal(err)
}
for _, cmd := range []string{"FLUSHDB", "FLUSHALL", "KEYS *", "SET a 1", "DEL a"} {
if _, err := CheckRedisReadOnly(cmd); err == nil {
t.Fatalf("expected deny for %q", cmd)
}
}
}
func TestWhitelistEmptyRejects(t *testing.T) {
rt := NewRuntime(DefaultPolicy()) // empty whitelist
rt.LoadConns = func() ([]SavedConnection, error) {
return []SavedConnection{{ID: "c1", Name: "n", Driver: "sqlite"}}, nil
}
rt.FindConn = func(id string) (*SavedConnection, error) {
return &SavedConnection{ID: id, Driver: "sqlite"}, nil
}
var audits []AuditEntry
rt.Audit = func(e AuditEntry) { audits = append(audits, e) }
_, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
"connection_id": "c1",
"sql": "SELECT 1",
})
if err == nil || !strings.Contains(err.Error(), "whitelist empty") {
t.Fatalf("expected whitelist empty error, got %v", err)
}
}
func TestDangerousSQLBlocked(t *testing.T) {
rt := NewRuntime(Policy{
AllowedConnectionIDs: []string{"c1"},
Mode: ModeReadOnly,
DefaultLimit: 100,
MaxLimit: 500,
})
rt.FindConn = func(id string) (*SavedConnection, error) {
return &SavedConnection{ID: id, Driver: "mysql", Host: "127.0.0.1", Port: 3306}, nil
}
called := false
rt.ExecSQL = func(ctx context.Context, c db.ConnReq) db.ExecResult {
called = true
return db.ExecResult{}
}
_, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
"connection_id": "c1",
"sql": "DROP TABLE users",
})
if err == nil {
t.Fatal("expected DROP blocked")
}
if called {
t.Fatal("ExecSQL must not be called for dangerous/write SQL")
}
}
func TestDecodeSecretOx1(t *testing.T) {
plain := "p@ss词"
enc := "ox1." + base64.StdEncoding.EncodeToString([]byte(plain))
if got := DecodeSecret(enc); got != plain {
t.Fatalf("got %q want %q", got, plain)
}
if DecodeSecret("plain") != "plain" {
t.Fatal("passthrough")
}
}
func TestSQLiteListAndExec(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "t.db")
if err := os.WriteFile(dbPath, []byte{}, 0600); err != nil {
t.Fatal(err)
}
init := db.ExecSQL(context.Background(), db.ConnReq{
Driver: "sqlite", Database: dbPath,
SQL: "CREATE TABLE person (id INTEGER PRIMARY KEY, name TEXT)",
})
if init.Error != "" {
t.Fatal(init.Error)
}
_ = db.ExecSQL(context.Background(), db.ConnReq{
Driver: "sqlite", Database: dbPath,
SQL: "INSERT INTO person (name) VALUES ('a')", ConfirmDanger: true,
})
rt := NewRuntime(Policy{
AllowedConnectionIDs: []string{"sqlite1"},
Mode: ModeReadOnly,
DefaultLimit: 100,
MaxLimit: 500,
})
rt.FindConn = func(id string) (*SavedConnection, error) {
return &SavedConnection{ID: id, Name: "local", Driver: "sqlite", Database: dbPath}, nil
}
rt.LoadConns = func() ([]SavedConnection, error) {
return []SavedConnection{{ID: "sqlite1", Name: "local", Driver: "sqlite", Database: dbPath}}, nil
}
out, err := rt.Call(context.Background(), ToolListTables, map[string]any{"connection_id": "sqlite1"})
if err != nil {
t.Fatal(err)
}
m := out.(map[string]any)
tables := m["tables"].([]string)
found := false
for _, tb := range tables {
if tb == "person" {
found = true
}
}
if !found {
t.Fatalf("tables=%v", tables)
}
desc, err := rt.Call(context.Background(), ToolDescribeTable, map[string]any{
"connection_id": "sqlite1", "table": "person",
})
if err != nil {
t.Fatal(err)
}
_ = desc
q, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
"connection_id": "sqlite1",
"sql": "SELECT name FROM person",
"limit": 10,
})
if err != nil {
t.Fatal(err)
}
qm := q.(map[string]any)
if qm["rowCount"].(int) < 1 {
t.Fatalf("rowCount=%v", qm["rowCount"])
}
// write rejected
if _, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
"connection_id": "sqlite1",
"sql": "INSERT INTO person (name) VALUES ('b')",
}); err == nil {
t.Fatal("insert must be rejected")
}
}
func TestListConnectionsFiltersWhitelist(t *testing.T) {
rt := NewRuntime(Policy{AllowedConnectionIDs: []string{"a"}, Mode: ModeReadOnly, DefaultLimit: 100, MaxLimit: 500})
rt.LoadConns = func() ([]SavedConnection, error) {
return []SavedConnection{
{ID: "a", Name: "A", Driver: "mysql", Host: "h", Port: 3306, Password: "secret"},
{ID: "b", Name: "B", Driver: "mysql", Host: "h2", Port: 3306},
}, nil
}
out, err := rt.Call(context.Background(), ToolListConnections, nil)
if err != nil {
t.Fatal(err)
}
conns := out.(map[string]any)["connections"].([]ConnectionSummary)
if len(conns) != 1 || conns[0].ID != "a" {
t.Fatalf("%+v", conns)
}
// ensure no password field leaked via JSON
raw := ResultJSON(out)
if strings.Contains(raw, "secret") || strings.Contains(raw, "password") {
t.Fatalf("password leaked: %s", raw)
}
}
func TestToolNamesStable(t *testing.T) {
names := ToolNames()
if len(names) != 7 {
t.Fatalf("want 7 tools, got %d", len(names))
}
if names[0] != ToolListConnections || names[len(names)-1] != ToolExecuteRedisCommand {
t.Fatalf("%v", names)
}
}
+30
View File
@@ -0,0 +1,30 @@
package aitools
import (
"encoding/base64"
"net/url"
"strings"
)
const secretPrefix = "ox1."
// DecodeSecret 与前端 src/secrets.ts decodeSecret 对齐:ox1. + base64(utf8)。 // Logan.Li
func DecodeSecret(stored string) string {
if stored == "" {
return ""
}
if !strings.HasPrefix(stored, secretPrefix) {
return stored
}
raw, err := base64.StdEncoding.DecodeString(stored[len(secretPrefix):])
if err != nil {
return stored
}
// 前端 encodeURIComponent 再 btoa;解码侧 escape/atob/decodeURIComponent。
// Go 侧:先按 latin1 字节还原,再尝试 URL unescape。
s := string(raw)
if u, err := url.QueryUnescape(s); err == nil {
return u
}
return s
}
+87 -5
View File
@@ -11,6 +11,7 @@ import (
"github.com/gin-gonic/gin"
"opsxdb/server/internal/ai"
"opsxdb/server/internal/aitools"
"opsxdb/server/internal/db"
"opsxdb/server/internal/docs"
"opsxdb/server/internal/site"
@@ -371,6 +372,28 @@ func NewRouter(token string) *gin.Engine {
c.JSON(http.StatusOK, gin.H{"ok": true, "imported": n, "drivers": db.DriversStatus()})
})
// POST /api/db/dump 表转储 SQL:format=structure|data|structure_data(对齐 Navicat「转储 SQL 文件」)
api.POST("/db/dump", func(c *gin.Context) {
req, err := bindConn(c, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if strings.TrimSpace(req.Table) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少表名"})
return
}
ctx := c.Request.Context()
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="dump-%s-%d.sql"`, sanitizeFileToken(req.Table), time.Now().Unix()))
c.Header("Content-Type", "application/sql; charset=utf-8")
if err := db.DumpTable(ctx, req, c.Writer); err != nil {
if !c.Writer.Written() {
c.Writer.Header().Del("Content-Disposition")
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
}
})
api.POST("/ai/chat", func(c *gin.Context) {
var req ai.ChatReq
if err := c.ShouldBindJSON(&req); err != nil {
@@ -389,11 +412,14 @@ func NewRouter(token string) *gin.Engine {
api.GET("/ai/config", func(c *gin.Context) {
cfg := ai.LoadConfig()
c.JSON(http.StatusOK, gin.H{
"provider": cfg.Provider,
"baseURL": cfg.BaseURL,
"model": cfg.Model,
"hasKey": cfg.APIKey != "",
"apiKey": cfg.MaskedKey(),
"provider": cfg.Provider,
"baseURL": cfg.BaseURL,
"model": cfg.Model,
"protocol": cfg.Protocol,
"authStyle": cfg.AuthStyle,
"allowInsecureHttp": cfg.AllowInsecureHTTP,
"hasKey": cfg.APIKey != "",
"apiKey": cfg.MaskedKey(),
})
})
@@ -403,6 +429,12 @@ func NewRouter(token string) *gin.Engine {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 前端 key 留空表示「不改动」:保留已存 key,避免掩码回传语义下误清空
if req.APIKey == "" {
if old := ai.LoadConfig(); old.APIKey != "" {
req.APIKey = old.APIKey
}
}
if err := ai.SaveConfig(req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -410,6 +442,38 @@ func NewRouter(token string) *gin.Engine {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
// 桌面连接落盘双写:供 MCP 进程读 ~/.opsxdb/connections.json(Logan.Li)
api.GET("/db/connections-store", func(c *gin.Context) {
list, err := aitools.LoadConnections()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if list == nil {
list = []aitools.SavedConnection{}
}
c.JSON(http.StatusOK, gin.H{"connections": list})
})
api.PUT("/db/connections-store", func(c *gin.Context) {
var req struct {
Connections []aitools.SavedConnection `json:"connections"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Connections == nil {
req.Connections = []aitools.SavedConnection{}
}
if err := aitools.SaveConnections(req.Connections); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Connections)})
})
api.POST("/ai/generate", func(c *gin.Context) {
var req ai.ChatReq
if err := c.ShouldBindJSON(&req); err != nil {
@@ -478,6 +542,24 @@ func exportContentType(format string) (contentType, ext string) {
return "", ""
}
// sanitizeFileToken 文件名安全化:库.表等标识符去掉路径分隔与控制字符。
func sanitizeFileToken(s string) string {
var b strings.Builder
for _, r := range strings.TrimSpace(s) {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
r == '-', r == '_', r == '.', r == '@':
b.WriteRune(r)
default:
b.WriteRune('_')
}
}
if b.Len() == 0 {
return "table"
}
return b.String()
}
func health(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true, "service": "opsxdb", "version": Version})
}
+84 -6
View File
@@ -6,6 +6,7 @@ declare global {
interface Window {
__OPSXDB_API__?: string;
__OPSXDB_TOKEN__?: string;
__OPSXDB_BOOT_ERROR__?: string;
}
}
@@ -96,6 +97,7 @@ export type ConnBody = {
confirmDanger?: boolean;
key?: string;
pattern?: string;
keyCursor?: string;
cursor?: number;
redisMode?: string;
redisNodes?: string;
@@ -152,6 +154,7 @@ export function sessionPayload(c: SavedConnection, sessionId: string, extra?: Pa
confirmDanger: extra?.confirmDanger,
key: extra?.key,
pattern: extra?.pattern,
keyCursor: extra?.keyCursor,
service: extra?.service ?? c.service,
requestId: extra?.requestId,
timeoutMs: extra?.timeoutMs,
@@ -419,10 +422,12 @@ export async function explainSql(
}
export type RedisKeyInfo = { name: string; type: string; ttl: number };
export type RedisKeyInfo = { name: string; type: string; ttl: number; exact?: boolean };
export type RedisKeysResult = {
keys: RedisKeyInfo[];
nextCursor?: string;
exactHit?: boolean;
truncated?: boolean;
timeMs?: number;
error?: string;
@@ -446,11 +451,11 @@ export type RedisKeyValue = {
export async function listRedisKeys(
c: SavedConnection,
database: string,
opts?: { sessionId?: string; pattern?: string; limit?: number },
opts?: { sessionId?: string; pattern?: string; limit?: number; keyCursor?: string },
) {
return post<RedisKeysResult>(
"/api/db/redis/keys",
body(c, opts?.sessionId, { database, pattern: opts?.pattern, limit: opts?.limit, driver: c.driver }),
body(c, opts?.sessionId, { database, pattern: opts?.pattern, limit: opts?.limit, keyCursor: opts?.keyCursor, driver: c.driver }),
);
}
@@ -520,27 +525,65 @@ export async function callAi(payload: {
return post<{ content: string; sql?: string }>("/api/ai/chat", payload);
}
/** C4:AI 配置(key 脱敏,只回掩码与是否已配置) */
/** C4:AI 配置(key 脱敏,只回掩码与是否已配置);protocol/authStyle/allowInsecureHttp 见 aiPresets.ts */
export type AiConfig = {
provider: string;
baseURL: string;
model: string;
hasKey: boolean;
apiKey?: string;
protocol?: string;
authStyle?: string;
allowInsecureHttp?: boolean;
};
export async function getAiConfig(signal?: AbortSignal) {
return get<AiConfig>("/api/ai/config", signal);
}
export async function saveAiConfig(cfg: { provider: string; baseURL: string; apiKey?: string; model: string }) {
/** 桌面连接双写到 ~/.opsxdb/connections.json,供 MCP 进程读取(P0)。失败不抛,避免挡保存。 */
export async function syncConnectionsToDisk(connections: SavedConnection[]): Promise<void> {
try {
await fetch(`${API}/api/db/connections-store`, {
method: "PUT",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({ connections }),
});
} catch {
/* 后端未起时忽略 */
}
}
export async function loadConnectionsFromDisk(): Promise<SavedConnection[] | null> {
try {
const res = await fetch(`${API}/api/db/connections-store`, { headers: { ...authHeaders() } });
if (!res.ok) return null;
const data = (await res.json()) as { connections?: SavedConnection[] };
return Array.isArray(data.connections) ? data.connections : null;
} catch {
return null;
}
}
export async function saveAiConfig(cfg: {
provider: string;
baseURL: string;
apiKey?: string;
model: string;
protocol?: string;
authStyle?: string;
allowInsecureHttp?: boolean;
}) {
return post<{ ok: boolean }>("/api/ai/config", cfg);
}
export async function health() {
const res = await fetch(`${API}/api/health`, { headers: { ...authHeaders() } });
if (!res.ok) throw new Error(`health ${res.status}`);
return res.json();
const data = await res.json() as { service?: string; version?: string };
if (data.service !== "opsxdb") throw new Error("当前地址不是 OpsxDB 后端");
return data;
}
/** fetch AbortController / DOMException AbortError */
@@ -590,3 +633,38 @@ export async function driverImportOffline(file: File) {
if (!res.ok) throw new Error((data as { error?: string }).error || res.statusText);
return data as { ok: boolean; error?: string; imported?: number; drivers?: DriverStatusItem[] };
}
/** 表转储范围:仅结构 / 仅数据 / 结构+数据(服务端 DumpTable 对应值) */
export type DumpMode = "structure" | "data" | "structure_data";
/** 表右键「转储 SQL 文件」:POST /api/db/download → blob 下载(.sql) */
export async function dumpTable(
c: SavedConnection,
opts: { table: string; database?: string; mode: DumpMode; sessionId?: string },
): Promise<{ blob: Blob; filename?: string }> {
const res = await fetch(`${API}/api/db/dump`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify(
body(c, opts.sessionId, { format: opts.mode, table: opts.table, database: opts.database || c.database }),
),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error((data as { error?: string }).error || res.statusText || "转储失败");
}
const cd = res.headers.get("Content-Disposition") || "";
const m = /filename\*?=(?:UTF-8''|\")?([^";]+)/i.exec(cd);
const filename = m ? decodeURIComponent(m[1].replace(/"$/, "").trim()) : undefined;
const blob = await res.blob();
return { blob, filename };
}
/** 触发浏览器下载 blob(导出/转储共用) */
export function downloadBlob(blob: Blob, filename: string) {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
+87 -14
View File
@@ -3,7 +3,8 @@ import type { DriverId } from "./catalog";
import { driverById } from "./catalog";
import { formatDbError, notifyId, type NotifyKind, type NotifyPayload } from "./notify";
import { decodeSecret, encodeSecret } from "./secrets";
import { dropSchemaCache } from "./api";
import { dropSchemaCache, syncConnectionsToDisk } from "./api";
import { normalizeRedisDatabase } from "./redisContext";
export type SavedConnection = {
id: string;
@@ -115,6 +116,8 @@ export type ResultSetItem = {
label: string;
sql: string;
result: QueryResult;
/** 仅对象预览填入,避免复制 INSERT 时误用其他标签的全局元数据。 */
source?: { connectionId: string; driver: string; database: string; table: string };
/** C5:分页状态(仅单条 SELECT 启用);page 从 0 起 */
page?: number;
pageSize?: number;
@@ -123,6 +126,8 @@ export type ResultSetItem = {
/** 列宽/选中态等视图持久化(修 G3,resultSet 级) */
viewState?: {
colWidths?: Record<string, number>;
sortCol?: string | null;
sortDir?: "asc" | "desc" | null;
selectedIdx?: number | null;
selectedCol?: string | null;
};
@@ -142,6 +147,7 @@ export type ColumnInfo = {
key?: string;
default?: unknown;
comment?: string;
extra?: string;
};
export type TreeNode = {
@@ -158,6 +164,8 @@ export type TreeNode = {
ttl?: number;
/** Redis 文件夹前缀(含分隔符),如 CEX:FOLLOW: */
fullName?: string;
/** Redis TYPE 精确命中(搜索置顶标「精确」) */
exact?: boolean;
nullable?: boolean;
key?: string;
};
@@ -192,7 +200,7 @@ export type ViewMode = "workbench" | "picker";
export type PickerMode = "list" | "drivers";
type State = {
theme: "dark" | "light";
theme: "dark" | "light" | "system";
view: ViewMode;
connections: SavedConnection[];
connGroups: ConnectionGroup[];
@@ -200,6 +208,8 @@ type State = {
activeConnectionId: string | null;
tree: TreeNode[];
treeLoading: boolean;
/** 侧栏 ↻ 等触发对象树重载 */
treeEpoch: number;
tabs: QueryTab[];
activeTabId: string;
result: QueryResult | null;
@@ -250,6 +260,7 @@ type State = {
viewDataRequest: { database: string; table: string; token: number } | null;
set: <K extends keyof State>(key: K, value: State[K]) => void;
requestTreeRefresh: () => void;
notify: (input: {
kind?: NotifyKind;
title?: string;
@@ -277,11 +288,13 @@ type State = {
openPicker: (driver?: DriverId | null) => void;
openConnectionList: () => void;
editConnection: (c: SavedConnection) => void;
newQueryTab: () => void;
newQueryTab: (database?: string) => void;
/** 重命名当前激活的查询 Tab(如打开表后标题设为表名) */
setActiveTabTitle: (title: string) => void;
/** 设置当前 Tab 的库上下文(Redis 编辑器 SELECT/USE 切库用) */
setTabDatabase: (database: string) => void;
setTabDatabase: (database: string, tabId?: string) => void;
/** 切连接时激活其查询标签,或新建查询;保留别的连接的 SQL 草稿。 */
selectConnection: (id: string) => void;
closeTab: (id: string) => void;
/** R2-F6:Tab 右键菜单批量关闭(脏确认由调用侧负责) */
closeOtherTabs: (id: string) => void;
@@ -290,6 +303,8 @@ type State = {
setStopOnError: (v: boolean) => void;
setExecTimeoutMs: (v: number) => void;
updateActiveSql: (sql: string) => void;
/** AI 异步返回只写入原标签;用户编辑或切库后跳过。 */
updateTabSqlIfUnchanged: (id: string, originalSql: string, connectionId: string | undefined, database: string | undefined, sql: string) => boolean;
pushAi: (role: AiMessage["role"], content: string) => void;
setSession: (connectionId: string, sessionId: string | null) => void;
pushHistory: (item: Omit<QueryHistoryItem, "id" | "at"> & { at?: number }) => void;
@@ -323,7 +338,7 @@ function loadPrefs(): Partial<Pick<State, "theme" | "leftWidth" | "aiWidth" | "r
if (!raw) return {};
const p = JSON.parse(raw) as Record<string, unknown>;
return {
theme: p.theme === "light" ? "light" : "dark",
theme: p.theme === "light" || p.theme === "system" ? p.theme : "dark",
leftWidth: typeof p.leftWidth === "number" ? Math.min(360, Math.max(200, p.leftWidth)) : undefined,
aiWidth: typeof p.aiWidth === "number" ? p.aiWidth : undefined,
resultRatio: typeof p.resultRatio === "number" ? p.resultRatio : undefined,
@@ -411,6 +426,11 @@ function defaultTab(): QueryTab {
return { id: crypto.randomUUID(), title: "查询 1", sql: "", savedSql: "", dirty: false };
}
/** 启动占位标签没有任何用户内容,可在真正创建标签时直接替换。 */
function isEmptyStartupTab(tab: QueryTab): boolean {
return !tab.connectionId && !tab.database && !tab.sql?.trim() && !tab.savedSql?.trim() && /^查询 1$/.test(tab.title);
}
export const useWorkspaceStore = create<State>((set, get) => ({
theme: "dark",
view: "workbench",
@@ -420,6 +440,7 @@ export const useWorkspaceStore = create<State>((set, get) => ({
activeConnectionId: null,
tree: [],
treeLoading: false,
treeEpoch: 0,
tabs: [defaultTab()],
activeTabId: "",
result: null,
@@ -463,7 +484,9 @@ export const useWorkspaceStore = create<State>((set, get) => ({
viewDataRequest: null,
set: (key, value) => {
set({ [key]: value } as Partial<State>);
set((state) => key === "activeConnectionId" && value !== state.activeConnectionId
? ({ [key]: value, sidebarFilter: "" } as Partial<State>)
: ({ [key]: value } as Partial<State>));
if (key === "theme" || key === "leftWidth" || key === "aiWidth" || key === "resultRatio" || key === "showAi" || key === "stopOnError" || key === "execTimeoutMs") {
get().persistPrefs();
}
@@ -473,6 +496,8 @@ export const useWorkspaceStore = create<State>((set, get) => ({
}
},
requestTreeRefresh: () => set((st) => ({ treeEpoch: (st.treeEpoch || 0) + 1 })),
notify: (input) => {
const parsed = input.raw !== undefined ? formatDbError(input.raw) : null;
const kind = input.kind ?? parsed?.kind ?? "info";
@@ -523,8 +548,10 @@ export const useWorkspaceStore = create<State>((set, get) => ({
}));
const prefs = loadPrefs();
const savedTabs = loadTabs();
const tabs = savedTabs?.tabs ?? get().tabs;
const activeTabId = savedTabs?.activeTabId || get().activeTabId || tabs[0]?.id || "";
const loadedTabs = savedTabs?.tabs ?? get().tabs;
const tabs = loadedTabs.length > 1 ? loadedTabs.filter(t => !isEmptyStartupTab(t)) : loadedTabs;
const activeTabId = tabs.some(t => t.id === savedTabs?.activeTabId)
? savedTabs!.activeTabId : tabs[0]?.id || "";
set({
connections: list,
connGroups: loadGroups(),
@@ -542,9 +569,12 @@ export const useWorkspaceStore = create<State>((set, get) => ({
password: encodeSecret(c.password || ""),
sshPassword: c.sshPassword ? encodeSecret(c.sshPassword) : c.sshPassword,
sshPassphrase: c.sshPassphrase ? encodeSecret(c.sshPassphrase) : c.sshPassphrase,
sentinelPassword: c.sentinelPassword ? encodeSecret(c.sentinelPassword) : c.sentinelPassword,
}));
localStorage.setItem(LS_KEY, JSON.stringify(encoded));
set({ connections: encoded });
// 双写 ~/.opsxdb/connections.json(经本机 Gin),供 opsxdb-mcp 读取。 // Logan.Li
void syncConnectionsToDisk(encoded);
},
upsertConnection: (c) => {
@@ -577,7 +607,7 @@ export const useWorkspaceStore = create<State>((set, get) => ({
dropSchemaCache(id);
const sessions = { ...get().sessions };
delete sessions[id];
if (get().activeConnectionId === id) set({ activeConnectionId: null, tree: [], sessions });
if (get().activeConnectionId === id) set({ activeConnectionId: null, sidebarFilter: "", tree: [], sessions });
else set({ sessions });
},
@@ -721,8 +751,12 @@ export const useWorkspaceStore = create<State>((set, get) => ({
});
},
newQueryTab: () => {
const n = get().tabs.length + 1;
newQueryTab: (database) => {
const current = get();
const existingTabs = current.tabs.filter(t => !isEmptyStartupTab(t));
const n = existingTabs.length + 1;
const conn = get().connections.find((c) => c.id === get().activeConnectionId);
const requestedDatabase = database ?? conn?.database;
const tab: QueryTab = {
id: crypto.randomUUID(),
title: `查询 ${n}`,
@@ -730,8 +764,9 @@ export const useWorkspaceStore = create<State>((set, get) => ({
savedSql: "",
dirty: false,
connectionId: get().activeConnectionId ?? undefined,
database: conn?.driver.toLowerCase() === "redis" ? normalizeRedisDatabase(requestedDatabase, conn) : requestedDatabase,
};
const tabs = [...get().tabs, tab];
const tabs = [...existingTabs, tab];
set({ tabs, activeTabId: tab.id });
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: tab.id }));
},
@@ -743,13 +778,42 @@ export const useWorkspaceStore = create<State>((set, get) => ({
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: s.activeTabId }));
},
setTabDatabase: (database) => {
setTabDatabase: (database, tabId) => {
const s = get();
const tabs = s.tabs.map((t) => (t.id === s.activeTabId ? { ...t, database } : t));
const id = tabId ?? s.activeTabId;
const target = s.tabs.find(t => t.id === id);
if (!target) return;
const conn = s.connections.find((c) => c.id === target.connectionId);
const nextDatabase = conn?.driver.toLowerCase() === "redis" ? normalizeRedisDatabase(database, conn) : database;
const tabs = s.tabs.map((t) => (t.id === id ? { ...t, database: nextDatabase } : t));
set({ tabs });
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: s.activeTabId }));
},
selectConnection: (id) => {
if (!get().connections.some(c => c.id === id)) return;
get().set("activeConnectionId", id);
const s = get();
const conn = s.connections.find(c => c.id === id)!;
const normalizeTab = (tabId: string) => {
if (conn.driver.toLowerCase() !== "redis") return;
const current = get();
const tabs = current.tabs.map(t => t.id === tabId ? { ...t, database: normalizeRedisDatabase(t.database ?? conn.database, conn) } : t);
set({ tabs });
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: current.activeTabId }));
};
if (s.tabs.find(t => t.id === s.activeTabId)?.connectionId === id) {
normalizeTab(s.activeTabId);
return;
}
const existing = s.tabs.find(t => t.connectionId === id);
if (existing) {
get().set("activeTabId", existing.id);
normalizeTab(existing.id);
}
else get().newQueryTab();
},
closeTab: (id) => {
const tabs = get().tabs.filter((t) => t.id !== id);
const next = tabs.length ? tabs : [defaultTab()];
@@ -805,6 +869,15 @@ export const useWorkspaceStore = create<State>((set, get) => ({
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: id }));
},
updateTabSqlIfUnchanged: (id, originalSql, connectionId, database, sql) => {
const current = get().tabs.find(t => t.id === id);
if (!current || current.sql !== originalSql || current.connectionId !== connectionId || current.database !== database) return false;
const tabs = get().tabs.map(t => t.id === id ? { ...t, sql, dirty: sql !== (t.savedSql ?? "") } : t);
set({ tabs });
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: get().activeTabId }));
return true;
},
pushAi: (role, content) =>
set((s) => ({
aiMessages: [