feat(jvm): 完成资源治理与诊断增强

- 新增 JMX/Endpoint/Agent 三种 JVM 连接模式与配置归一化链路
- 支持资源浏览、变更预览、写入应用、审计记录与只读约束
- 接入 AI 结构化写入计划与诊断计划回填能力
- 新增 Agent Bridge、Arthas Tunnel、JMX Helper 诊断传输实现
- 增加诊断控制台、命令模板、输出历史与自动补全交互
- 补齐前后端契约、运行夹具与 JVM 相关回归测试
This commit is contained in:
Syngnat
2026-04-24 16:45:34 +08:00
parent d9b4c6a21b
commit 6f14e827ab
79 changed files with 18941 additions and 4349 deletions

View File

@@ -33,7 +33,7 @@ GoNavi 现有已具备三类可复用基础:
- 不承诺“任意 JVM 内任意对象均可直接读写”
- 不在首期支持任意 Java 表达式执行、任意反射路径写值或任意 classloader 深度探测
- 不把 JVM 功能强行塞进现有 SQL `Database` / driver-agent 抽象
-在首期承诺通过 Agent 模式支持所有缓存框架
- 不承诺通过 Agent 模式支持所有缓存框架或任意深层对象写入
- 不绕过目标服务现有认证、鉴权和网络边界
## 4. 需求与约束
@@ -102,12 +102,11 @@ JVM 缓存可视化编辑应当比照 Redis 独立建模,新增 `JVM Connector
## 7. 选型
采用方案 B,首期 MVP 聚焦
采用方案 B。当前已落地
- `JMX Provider`
- `Management Endpoint Provider`
`Agent Provider` 只保留扩展位与接口约束,不纳入首期交付承诺。
- `Agent Provider`(高级可选模式,要求目标 Java 服务显式预埋 GoNavi Java Agent
## 8. 目标架构
@@ -169,11 +168,12 @@ JVM 缓存可视化编辑应当比照 Redis 独立建模,新增 `JVM Connector
### 9.3 Agent Provider
- 负责:
- 在特定环境下提供更深层的运行时对象探测与写入能力
- 在特定环境下通过 GoNavi Java Agent 暴露受控管理端口
- 提供比 JMX 更贴近缓存资源模型的结构化浏览、预览与写入能力
- 定位:
- 高级模式
- 不默认启用
- 不在首期交付
- 需要目标 Java 服务以 `-javaagent` 方式显式启动
## 10. 统一资源模型
@@ -283,7 +283,7 @@ AI 不直接输出脚本,而输出结构化变更计划,例如:
- `JMX`
- `Endpoint`
- `Agent(禁用)`
- `Agent`
- `只读`
- `可写`
@@ -415,10 +415,10 @@ JVM 审计日志不应复用 SQL 日志数据结构,但可以复用现有 LogP
- AI 回滚建议
- 仍默认不允许 AI 自动执行
### Phase 4高级模式预留
### Phase 4高级模式
- Agent Provider SPI
- 更深层 runtime 能力
- Agent Provider
- 预埋 Java Agent 的 runtime 资源治理能力
- 仅在特殊环境启用
## 16. 验证策略
@@ -476,7 +476,7 @@ JVM 缓存可视化编辑能力在 GoNavi 中具备落地基础,但必须采
- 新增独立的 `JVM Connector` 子系统
- 首期支持 `JMX + Management Endpoint`
- `Agent` 仅保留扩展位
- `Agent` 作为高级可选模式交付
- AI 首期支持分析与生成修改计划,不默认开放自动执行
- 所有修改必须经过预览、确认、审计和回读验证

View File

@@ -4,22 +4,26 @@
- 需求名称JVM缓存可视化编辑
- 提出日期2026-04-22
- 负责人Codex
- 目标:评估并设计 GoNavi 连接 Java JVM、可视化查看并修改 JVM 内缓存/对象值的能力,降低“改缓存只能写接口或重启应用”的运维与排障成本
- 非目标:不直接承诺覆盖所有 Java 框架/所有对象类型,不在需求未澄清前进入实现,不绕过目标应用现有安全控制
- 目标:完成 GoNavi 连接 Java JVM、可视化查看并修改 JVM 内缓存/对象值的通用能力交付,降低“改缓存只能写接口或重启应用”的运维与排障成本
- 非目标:不承诺覆盖所有 Java 框架/所有对象类型,不绕过目标应用现有安全控制,不在首期开放脚本式任意表达式执行
## 2. 范围与验收
- 范围:
- 评估 GoNavi 现有驱动代理模型是否可扩展到 JVM 运行时连接
- 明确 JVM 缓存可视化编辑的接入方式、权限边界与最小可行产品范围
- 输出推荐方案、关键风险与后续实施方向
- 交付 JVM 共享契约、连接配置、provider 注册、连接测试与能力探测
- 交付 Endpoint / JMX / Agent 三种接入模式及其资源浏览、读值、预览、执行链路
- 交付 JVM 资源页、预览弹窗、审计查看、AI 草稿生成与回填能力
- 交付 Guard、审计、来源标记、真实集成测试与构建验证
- 验收标准:
- 明确该需求在 GoNavi 内“能不能做”
- 明确推荐接入模式、前后端分层与安全边界
- 明确首期建议支持的缓存类型/Java 技术栈范围
- 可以在 GoNavi 中新增 JVM 连接并完成连接测试
- 可以按资源树浏览 JVM 对象并查看结构化快照
- 可以对支持写入的资源执行预览和确认写入,且带 Guard 与审计
- 可以通过 AI 生成结构化修改草稿,但不会跳过人工确认直接执行
- 可以通过真实 JMX 与真实 HTTP contract 完成端到端验证,并通过前后端构建回归
- 依赖与约束:
- 需复用 GoNavi 当前 Wails + React + driver-agent 架构
- 新能力不得破坏现有数据库/Redis 工作流
- 高风险写操作必须具备明确鉴权、审计与回滚思路
- JMX 模式要求 GoNavi 运行机器本地可用 `java` 可执行文件
## 3. 里程碑与进度
- [x] 阶段 1需求澄清完成
@@ -50,9 +54,17 @@
- 已完成 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 能力探测遵循只读配置,概览页能力矩阵补齐模式能力探测与多行错误展示,能力探测失败与风险/结果状态文案统一收口为中文业务语义
- 待处理:
- 真实 provider 的 `PreviewChange` / `ApplyChange` 端到端能力验证
- AI 参与信息写入 JVM 审计记录
- 无阻塞性交付项;后续仅保留复杂对象参数、`CompositeData` / `TabularData` 等高级类型写入扩展作为增强项
## 5. 风险与阻塞
- 风险:
@@ -61,12 +73,12 @@
- 若依赖 attach agent 或表达式执行,需严格控制安全边界与可观测性
- 若目标 JVM 不允许预埋或动态注入 Agent则“通用型”能力边界会明显收缩
- 多接入模式会带来能力不一致问题UI 与权限模型必须显式展示“当前模式支持什么/不支持什么”
- 当前 JMX / Endpoint provider 的资源浏览与值读取仍是骨架实现Task 4 已打通接口与 UI 链路,但真实资源展开会返回 `not implemented`
- Task 5 已打通 Guard/Preview/Audit 主链路,但真实 provider 的 `PreviewChange` / `ApplyChange` 能力仍依赖后续 provider 细化实现
- 当前 AI 能力边界仍是“分析 + 生成结构化计划 + 回填预览草稿”,不直接执行 JVM 写入,真实执行仍取决于 Guard、人工确认和 provider 能力
- 当前 MVP 中 `updateValue` 会映射为现有 JVM 变更 contract 的 `put`,且 payload 仅接受 JSON 对象;非对象值、复杂二进制值和 provider 专有写法不在本期承诺范围
- AI 计划若只提供 `namespace + key`,当前仅按 `namespace/key` 推导资源 ID属于 MVP 级资源定位约定,未承诺适配所有 provider 的资源 ID 规则
- JVM 审计记录当前未显式记录“AI 是否参与”,与理想审计模型仍有差距
- 当前 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 资源页重新生成计划
- 阻塞:
- 当前开发收口阶段无新增阻塞
@@ -74,8 +86,9 @@
- 优先收敛到标准接入面JMX / Spring Actuator / Java Agent 三选一)
- 首期只支持白名单对象类型与受控写操作
- 要求变更审计、预览、确认与失败回滚路径
- 在交付说明中明确“AI 只生成草稿,不代表 provider 已具备真实写能力
- 后续如进入真实接入阶段,优先补齐 provider 端到端验证与 AI 审计字段
- 在交付说明中明确“AI 只生成草稿,不直接执行 JVM 写入
- JMX helper 改为内嵌 runtime jar默认写入用户缓存目录必要时允许通过 `GONAVI_JMX_HELPER_CLASSPATH` 覆盖 classpath
- 对复杂参数调用保持白名单和人工确认,不开放脚本式自由执行
## 6. 决策记录
- 决策 1先做可行性评估与方案设计不直接进入实现
@@ -86,6 +99,8 @@
- 决策 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. 验证记录
- 验证项:
@@ -96,6 +111,9 @@
- 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前端生产构建
@@ -117,11 +135,24 @@
- 前端已完成 JVM 变更草稿区、预览弹窗、执行确认、审计记录页签与按 provider mode 的审计过滤
- 已完成 Task 6AI 计划解析、资源定位解析、`updateValue -> put` 显式映射、JSON 对象 payload 约束和上下文绑定单测
- 已完成 Task 6AI 聊天消息与 JVM 来源页签绑定AI 气泡应用按钮不再依赖点击时的 `activeTabId`,避免跨 JVM 页签误投递
- `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts` 通过10 tests
- 已完成 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 files258 tests
- `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`
@@ -145,7 +176,15 @@
- `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`
@@ -195,9 +234,13 @@
- `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. 下一步
- 下一步行动:如进入真实接入阶段,优先补齐 provider 端到端写入验证、AI 参与审计字段和生产环境灰度验证
- 下一步行动:由用户按真实 JVM / endpoint 场景执行验收验证;若验收通过,再决定是否提交、推送或继续扩展高级类型写入
- 负责人Codex

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 type { AIChatMessage, AIToolCall, JVMAIPlanContext } from '../types';
import type {
AIChatMessage,
AIToolCall,
JVMAIPlanContext,
JVMDiagnosticPlanContext,
} from '../types';
import { DownOutlined } from '@ant-design/icons';
import './AIChatPanel.css';
@@ -232,6 +237,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
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);
@@ -274,6 +280,25 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
};
}, []);
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(() => {
if (!aiPanelVisible) return;
@@ -532,6 +557,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
rawError: rawErr,
timestamp: Date.now(),
jvmPlanContext: pendingJVMPlanContextRef.current,
jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current,
});
}
assistantMsgId = '';
@@ -553,6 +579,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
timestamp: Date.now(),
loading: true,
jvmPlanContext: pendingJVMPlanContextRef.current,
jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current,
});
}
}
@@ -570,6 +597,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
timestamp: Date.now(),
loading: true,
jvmPlanContext: pendingJVMPlanContextRef.current,
jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current,
});
if (sending) setSending(false);
} else {
@@ -589,6 +617,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
timestamp: Date.now(),
loading: true,
jvmPlanContext: pendingJVMPlanContextRef.current,
jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current,
});
setSending(false);
const currentHistory = useStore.getState().aiChatHistory[sid] || [];
@@ -649,7 +678,10 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
if (m.tool_call_id) mapped.tool_call_id = m.tool_call_id;
return mapped;
});
const sysMessages = await buildSystemContextMessages(existing.jvmPlanContext);
const sysMessages = await buildSystemContextMessages(
existing.jvmPlanContext,
existing.jvmDiagnosticPlanContext,
);
// 追加催促消息
messagesPayload.push({ role: 'user', content: '请直接使用 function call 调用工具执行操作,不要只用文字描述计划。' });
const allMsg = [...sysMessages, ...messagesPayload];
@@ -751,7 +783,10 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
totalToolRoundRef.current = 0;
nudgeCountRef.current = 0;
const retryJVMPlanContext = msg.jvmPlanContext || getCurrentJVMPlanContext();
const retryJVMDiagnosticPlanContext =
msg.jvmDiagnosticPlanContext || getCurrentJVMDiagnosticPlanContext();
pendingJVMPlanContextRef.current = retryJVMPlanContext;
pendingJVMDiagnosticPlanContextRef.current = retryJVMDiagnosticPlanContext;
setSending(true);
@@ -760,6 +795,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
id: genId(), role: 'assistant', phase: 'connecting', content: '',
timestamp: Date.now(), loading: true,
jvmPlanContext: retryJVMPlanContext,
jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext,
};
addAIChatMessage(sid, connectingMsg);
@@ -767,7 +803,10 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const messagesPayload = truncatedHistory.map(m => ({ role: m.role, content: m.content, images: m.images }));
try {
const sysMessages = await buildSystemContextMessages(retryJVMPlanContext);
const sysMessages = await buildSystemContextMessages(
retryJVMPlanContext,
retryJVMDiagnosticPlanContext,
);
const allMessages = [...sysMessages, ...messagesPayload];
const Service = (window as any).go?.aiservice?.Service;
@@ -783,6 +822,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
rawError: (!result?.success && errClean !== errRaw) ? errRaw : undefined,
timestamp: Date.now(),
jvmPlanContext: retryJVMPlanContext,
jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext,
});
setSending(false);
} else {
@@ -798,35 +838,93 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
rawError: cleanE !== rawE ? rawE : undefined,
timestamp: Date.now(),
jvmPlanContext: retryJVMPlanContext,
jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext,
});
setSending(false);
}
}
}, [sid, truncateAIChatMessages, addAIChatMessage, getCurrentJVMPlanContext]);
}, [
sid,
truncateAIChatMessages,
addAIChatMessage,
getCurrentJVMPlanContext,
getCurrentJVMDiagnosticPlanContext,
]);
const buildSystemContextMessages = useCallback(async (overrideJVMPlanContext?: JVMAIPlanContext) => {
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 activeTab = overrideJVMPlanContext
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 === 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 === overrideJVMDiagnosticPlanContext.tabId && matchesDiagnosticContext(t)) ||
allTabs.find(t => matchesDiagnosticContext(t))
)
: allTabs.find(t => t.id === tabId);
: 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') &&
@@ -850,9 +948,9 @@ ${resourcePath ? `当前资源路径:${resourcePath}` : '当前未选中具体
回答规则:
1. 你可以解释资源结构、风险、修改建议和回滚建议。
2. 如果用户要求生成 JVM 修改方案,必须输出一个唯一的 \`\`\`json 代码块,并且 JSON 字段严格限定为 targetType、selector、action、payload、reason。
3. action 只能使用 updateValue、evict、clear 之一
3. action 优先使用当前资源快照或元数据里已经声明的 supportedActions如果当前资源没有声明再基于快照内容谨慎推断
4. selector.resourcePath 优先使用当前资源路径;如果当前路径未知,就明确说明无法精确定位,不要编造路径。
5. 当前 JVM 预览 MVP 只支持 JSON 对象变更:如果 action=updateValue payload 必须是 {"format":"json","value":{...}},且 value 必须是 JSON 对象evict/clear 时 payload 可以省略
5. payload 只能使用 {"format":"json","value":{...}} 或 {"format":"text","value":"..."} 这两种包装形式,不要输出脚本、命令或裸值
6. 不要输出脚本、命令或“已经执行成功”之类的表述。`
});
return systemMessages;
@@ -927,7 +1025,10 @@ SELECT * FROM users WHERE status = 1;
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;
@@ -939,6 +1040,7 @@ SELECT * FROM users WHERE status = 1;
content: `⚠️ 工具调用已达 ${MAX_TOOL_CALL_ROUNDS} 轮上限,自动终止循环。如需继续探索,请发送新的消息。`,
timestamp: Date.now(),
jvmPlanContext: inheritedJVMPlanContext,
jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext,
});
setSending(false);
return;
@@ -1128,6 +1230,7 @@ SELECT * FROM users WHERE status = 1;
content: '⚠️ 探针连续 3 轮执行失败,自动终止。请检查连接状态后重试。',
timestamp: Date.now(),
jvmPlanContext: inheritedJVMPlanContext,
jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext,
});
setSending(false);
return;
@@ -1143,6 +1246,7 @@ SELECT * FROM users WHERE status = 1;
content: '汇总探针执行结果中',
timestamp: Date.now(), loading: true,
jvmPlanContext: inheritedJVMPlanContext,
jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext,
};
useStore.getState().addAIChatMessage(sid, chainConnectingMsg);
@@ -1169,7 +1273,10 @@ SELECT * FROM users WHERE status = 1;
if (m.tool_call_id) mapped.tool_call_id = m.tool_call_id;
return mapped;
});
const sysMessages = await buildSystemContextMessages(inheritedJVMPlanContext);
const sysMessages = await buildSystemContextMessages(
inheritedJVMPlanContext,
inheritedJVMDiagnosticPlanContext,
);
let finalMessagesPayload = messagesPayload;
// 在这里加入长度检查和自动摘要(带上动态限额)
@@ -1208,6 +1315,7 @@ SELECT * FROM users WHERE status = 1;
rawError: (!result?.success && errC !== errR) ? errR : undefined,
timestamp: Date.now(),
jvmPlanContext: inheritedJVMPlanContext,
jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext,
});
setSending(false);
}
@@ -1236,7 +1344,9 @@ SELECT * FROM users WHERE status = 1;
totalToolRoundRef.current = 0; // 重置总轮次计数
nudgeCountRef.current = 0; // 重置催促计数
const currentJVMPlanContext = getCurrentJVMPlanContext();
const currentJVMDiagnosticPlanContext = getCurrentJVMDiagnosticPlanContext();
pendingJVMPlanContextRef.current = currentJVMPlanContext;
pendingJVMDiagnosticPlanContextRef.current = currentJVMDiagnosticPlanContext;
const currentImages = [...draftImages];
setInput('');
@@ -1257,10 +1367,14 @@ SELECT * FROM users WHERE status = 1;
id: genId(), role: 'assistant', phase: 'connecting', content: '',
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: '模型接入中' });
@@ -1309,6 +1423,7 @@ SELECT * FROM users WHERE status = 1;
rawError: (!result?.success && errC2 !== errR2) ? errR2 : undefined,
timestamp: Date.now(),
jvmPlanContext: currentJVMPlanContext,
jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext,
};
addAIChatMessage(sid, assistantMsg);
setSending(false);
@@ -1318,16 +1433,42 @@ 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, getCurrentJVMPlanContext]);
}, [
input,
draftImages,
sending,
messages,
addAIChatMessage,
sid,
activeProvider,
buildSystemContextMessages,
getCurrentJVMPlanContext,
getCurrentJVMDiagnosticPlanContext,
]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,26 @@
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 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 JVMModeBadge from './jvm/JVMModeBadge';
import { useStore } from "../store";
import type { JVMAuditRecord, TabData } from "../types";
import {
formatJVMAuditResultLabel,
formatJVMActionDisplayText,
resolveJVMAuditResultColor,
} from "../utils/jvmResourcePresentation";
import JVMModeBadge from "./jvm/JVMModeBadge";
const { Text } = Typography;
@@ -25,90 +40,109 @@ const normalizeAuditRecords = (value: any): JVMAuditRecord[] => {
return [];
};
const filterAuditRecordsByMode = (records: JVMAuditRecord[], providerMode?: string): JVMAuditRecord[] => {
const normalizedMode = String(providerMode || '').trim().toLowerCase();
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);
return records.filter(
(record) =>
String(record.providerMode || "")
.trim()
.toLowerCase() === normalizedMode,
);
};
const formatTimestamp = (timestamp: number): string => {
if (!timestamp) {
return '-';
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 resultColor = (result: string): string => {
const normalized = String(result || '').trim().toLowerCase();
if (normalized.includes('success') || normalized.includes('ok') || normalized.includes('done')) {
return 'green';
}
if (normalized.includes('warn')) {
return 'gold';
}
if (normalized.includes('fail') || normalized.includes('error')) {
return 'red';
}
return 'default';
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 connection = useStore((state) =>
state.connections.find((item) => item.id === tab.connectionId),
);
const [limit, setLimit] = useState(50);
const [loading, setLoading] = useState(true);
const [records, setRecords] = useState<JVMAuditRecord[]>([]);
const [error, setError] = useState('');
const [error, setError] = useState("");
const columns = useMemo<ColumnsType<JVMAuditRecord>>(
() => [
{
title: '时间',
dataIndex: 'timestamp',
key: 'timestamp',
title: "时间",
dataIndex: "timestamp",
key: "timestamp",
width: 180,
render: (value: number) => formatTimestamp(value),
},
{
title: '模式',
dataIndex: 'providerMode',
key: 'providerMode',
title: "模式",
dataIndex: "providerMode",
key: "providerMode",
width: 120,
render: (value: string) => <JVMModeBadge mode={value || tab.providerMode || 'jmx'} />,
render: (value: string) => (
<JVMModeBadge mode={value || tab.providerMode || "jmx"} />
),
},
{
title: '动作',
dataIndex: 'action',
key: 'action',
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) => value || '-',
render: (value?: string) => {
const normalized = String(value || "")
.trim()
.toLowerCase();
if (normalized === "ai-plan") {
return <Tag color="purple">AI </Tag>;
}
return <Tag></Tag>;
},
},
{
title: '资源',
dataIndex: 'resourceId',
key: 'resourceId',
ellipsis: true,
render: (value: string) => value || '-',
},
{
title: '原因',
dataIndex: 'reason',
key: 'reason',
ellipsis: true,
render: (value: string) => value || '-',
},
{
title: '结果',
dataIndex: 'result',
key: 'result',
title: "结果",
dataIndex: "result",
key: "result",
width: 140,
render: (value: string) => <Tag color={resultColor(value)}>{value || 'unknown'}</Tag>,
render: (value: string) => (
<Tag color={resolveJVMAuditResultColor(value)}>
{formatJVMAuditResultLabel(value)}
</Tag>
),
},
],
[tab.providerMode],
@@ -118,31 +152,36 @@ const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
if (!connection) {
setLoading(false);
setRecords([]);
setError('连接不存在或已被删除');
setError("连接不存在或已被删除");
return;
}
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.JVMListAuditRecords !== 'function') {
if (typeof backendApp?.JVMListAuditRecords !== "function") {
setLoading(false);
setRecords([]);
setError('JVMListAuditRecords 后端方法不可用');
setError("JVMListAuditRecords 后端方法不可用");
return;
}
setLoading(true);
setError('');
setError("");
try {
const result = await backendApp.JVMListAuditRecords(connection.id, limit);
if (result?.success === false) {
setRecords([]);
setError(String(result?.message || '读取 JVM 审计记录失败'));
setError(String(result?.message || "读取 JVM 审计记录失败"));
return;
}
setRecords(filterAuditRecordsByMode(normalizeAuditRecords(result), tab.providerMode));
setRecords(
filterAuditRecordsByMode(
normalizeAuditRecords(result),
tab.providerMode,
),
);
} catch (err: any) {
setRecords([]);
setError(err?.message || '读取 JVM 审计记录失败');
setError(err?.message || "读取 JVM 审计记录失败");
} finally {
setLoading(false);
}
@@ -153,23 +192,38 @@ const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
}, [connection, limit, tab.connectionId]);
if (!connection) {
return <Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />;
return (
<Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />
);
}
return (
<div style={{ padding: 20, display: 'grid', gap: 16 }}>
<div style={{ padding: 20, display: "grid", gap: 16 }}>
<Card>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Space direction="vertical" size={12} style={{ width: "100%" }}>
<Space size={12} wrap>
<JVMModeBadge mode={tab.providerMode || connection.config.jvm?.preferredMode || 'jmx'} />
<Button size="small" icon={<ReloadOutlined />} onClick={() => void loadRecords()}>
<JVMModeBadge
mode={
tab.providerMode ||
connection.config.jvm?.preferredMode ||
"jmx"
}
/>
<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}` }))}
options={LIMIT_OPTIONS.map((item) => ({
value: item,
label: `最近 ${item}`,
}))}
style={{ width: 128 }}
/>
</Space>
@@ -181,16 +235,18 @@ const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
</Card>
<Card title="审计记录">
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<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}`}
rowKey={(record) =>
`${record.timestamp}-${record.resourceId}-${record.action}`
}
loading={loading}
columns={columns}
dataSource={records}
pagination={false}
locale={{
emptyText: error ? '当前无法加载审计记录' : '暂无审计记录',
emptyText: error ? "当前无法加载审计记录" : "暂无审计记录",
}}
scroll={{ x: 960 }}
size="small"

View File

@@ -0,0 +1,60 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import JVMDiagnosticConsole from "./JVMDiagnosticConsole";
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-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(),
}),
}));
describe("JVMDiagnosticConsole", () => {
it("shows observe command presets by default", () => {
const markup = renderToStaticMarkup(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
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"');
});
});

View File

@@ -0,0 +1,529 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import Editor, { type OnMount } from "@monaco-editor/react";
import {
Alert,
Button,
Card,
Empty,
Input,
message,
Space,
Tag,
Typography,
} from "antd";
import { EventsOn } from "../../wailsjs/runtime";
import { useStore } from "../store";
import type {
JVMDiagnosticAuditRecord,
JVMDiagnosticCapability,
JVMDiagnosticEventChunk,
JVMDiagnosticSessionHandle,
TabData,
} from "../types";
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
import { resolveJVMDiagnosticCompletionItems } from "../utils/jvmDiagnosticCompletion";
import { JVM_DIAGNOSTIC_COMMAND_PRESETS } from "../utils/jvmDiagnosticPresentation";
import JVMCommandPresetBar from "./jvm/JVMCommandPresetBar";
import JVMDiagnosticHistory from "./jvm/JVMDiagnosticHistory";
import JVMDiagnosticOutput from "./jvm/JVMDiagnosticOutput";
const { Text, Paragraph } = Typography;
const JVM_DIAGNOSTIC_EDITOR_LANGUAGE = "jvm-diagnostic";
let jvmDiagnosticLanguageRegistered = false;
let jvmDiagnosticCompletionRegistered = false;
type JVMDiagnosticConsoleProps = {
tab: TabData;
};
const DEFAULT_COMMAND =
JVM_DIAGNOSTIC_COMMAND_PRESETS.find((item) => item.category === "observe")
?.command || "thread -n 5";
const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
const connection = useStore((state) =>
state.connections.find((item) => item.id === tab.connectionId),
);
const draft = useStore(
(state) => state.jvmDiagnosticDrafts[tab.id] || { command: "" },
);
const chunks = useStore(
(state) => state.jvmDiagnosticOutputs[tab.id] || [],
);
const setDraft = useStore((state) => state.setJVMDiagnosticDraft);
const appendOutput = useStore((state) => state.appendJVMDiagnosticOutput);
const clearOutput = useStore((state) => state.clearJVMDiagnosticOutput);
const darkMode = useStore((state) => state.theme === "dark");
const [capabilities, setCapabilities] = useState<JVMDiagnosticCapability[]>([]);
const [session, setSession] = useState<JVMDiagnosticSessionHandle | null>(null);
const [records, setRecords] = useState<JVMDiagnosticAuditRecord[]>([]);
const [loading, setLoading] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const [commandRunning, setCommandRunning] = useState(false);
const [activeCommandId, setActiveCommandId] = useState("");
const [error, setError] = useState("");
useEffect(() => {
if (!draft.command) {
setDraft(tab.id, { command: DEFAULT_COMMAND, source: "manual" });
}
}, [draft.command, setDraft, tab.id]);
const diagnosticTransport = useMemo(
() => connection?.config.jvm?.diagnostic?.transport || "agent-bridge",
[connection],
);
const rpcConnectionConfig = useMemo(
() =>
connection
? buildRpcConnectionConfig(connection.config, { id: connection.id })
: null,
[connection],
);
const effectiveSession = useMemo(
() =>
session ||
(draft.sessionId
? {
sessionId: draft.sessionId,
transport: diagnosticTransport,
startedAt: 0,
}
: null),
[diagnosticTransport, draft.sessionId, session],
);
const loadAuditRecords = useCallback(async () => {
if (!connection) {
setRecords([]);
return;
}
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.JVMListDiagnosticAuditRecords !== "function") {
return;
}
setHistoryLoading(true);
try {
const result = await backendApp.JVMListDiagnosticAuditRecords(connection.id, 20);
if (result?.success === false) {
throw new Error(String(result?.message || "加载诊断历史失败"));
}
setRecords(Array.isArray(result?.data) ? result.data : []);
} catch (err: any) {
setError(err?.message || "加载诊断历史失败");
} finally {
setHistoryLoading(false);
}
}, [connection]);
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent).detail;
if (!detail || detail.targetTabId !== tab.id || !detail.plan) {
return;
}
const planTransport = String(detail.plan.transport || diagnosticTransport);
if (planTransport !== diagnosticTransport) {
setError(
`AI 计划的诊断 transport 为 ${planTransport},与当前控制台 ${diagnosticTransport} 不一致,请重新生成计划后再应用。`,
);
return;
}
setError("");
setDraft(tab.id, {
command: String(detail.plan.command || ""),
reason: String(detail.plan.reason || ""),
source: "ai-plan",
});
message.success("AI 诊断计划已回填到控制台");
};
window.addEventListener("gonavi:jvm-apply-diagnostic-plan", handler);
return () =>
window.removeEventListener("gonavi:jvm-apply-diagnostic-plan", handler);
}, [diagnosticTransport, setDraft, tab.id]);
useEffect(() => {
void loadAuditRecords();
}, [loadAuditRecords]);
useEffect(() => {
const eventName = "jvm:diagnostic:chunk";
const stopListening = EventsOn(eventName, (payload: {
tabId?: string;
chunk?: JVMDiagnosticEventChunk;
}) => {
if (!payload || payload.tabId !== tab.id || !payload.chunk) {
return;
}
appendOutput(tab.id, [payload.chunk]);
if (payload.chunk.phase === "failed") {
setError(payload.chunk.content || "诊断命令执行失败");
}
if (
payload.chunk.commandId &&
["completed", "failed", "canceled"].includes(payload.chunk.phase || "")
) {
if (payload.chunk.commandId === activeCommandId) {
setCommandRunning(false);
setActiveCommandId("");
}
void loadAuditRecords();
}
});
return () => {
if (typeof stopListening === "function") {
stopListening();
}
};
}, [activeCommandId, appendOutput, loadAuditRecords, tab.id]);
const handleProbe = async () => {
if (!rpcConnectionConfig) {
return;
}
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.JVMProbeDiagnosticCapabilities !== "function") {
setError("JVMProbeDiagnosticCapabilities 后端方法不可用");
return;
}
setLoading(true);
setError("");
try {
const result = await backendApp.JVMProbeDiagnosticCapabilities(
rpcConnectionConfig,
);
if (result?.success === false) {
throw new Error(String(result?.message || "探测诊断能力失败"));
}
setCapabilities(Array.isArray(result?.data) ? result.data : []);
} catch (err: any) {
setCapabilities([]);
setError(err?.message || "探测诊断能力失败");
} finally {
setLoading(false);
}
};
const handleStartSession = async () => {
if (!rpcConnectionConfig) {
return;
}
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.JVMStartDiagnosticSession !== "function") {
setError("JVMStartDiagnosticSession 后端方法不可用");
return;
}
setLoading(true);
setError("");
try {
const result = await backendApp.JVMStartDiagnosticSession(
rpcConnectionConfig,
{
title: "JVM 诊断控制台",
reason: draft.reason || "控制台启动会话",
},
);
if (result?.success === false) {
throw new Error(String(result?.message || "创建诊断会话失败"));
}
const nextSession = (result?.data || null) as JVMDiagnosticSessionHandle | null;
setSession(nextSession);
if (nextSession?.sessionId) {
setDraft(tab.id, { sessionId: nextSession.sessionId });
}
void loadAuditRecords();
} catch (err: any) {
setSession(null);
setError(err?.message || "创建诊断会话失败");
} finally {
setLoading(false);
}
};
const handleExecuteCommand = async () => {
if (!rpcConnectionConfig) {
return;
}
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.JVMExecuteDiagnosticCommand !== "function") {
setError("JVMExecuteDiagnosticCommand 后端方法不可用");
return;
}
if (!effectiveSession?.sessionId) {
setError("请先创建诊断会话,再执行命令");
return;
}
if (!draft.command.trim()) {
setError("诊断命令不能为空");
return;
}
const commandId = `diag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
setCommandRunning(true);
setActiveCommandId(commandId);
setError("");
try {
const result = await backendApp.JVMExecuteDiagnosticCommand(
rpcConnectionConfig,
tab.id,
{
sessionId: effectiveSession.sessionId,
commandId,
command: draft.command.trim(),
source: draft.source || "manual",
reason: (draft.reason || "").trim(),
},
);
if (result?.success === false) {
throw new Error(String(result?.message || "执行诊断命令失败"));
}
if (result?.message) {
message.warning(result.message);
}
await loadAuditRecords();
} catch (err: any) {
setCommandRunning(false);
setActiveCommandId("");
setError(err?.message || "执行诊断命令失败");
}
};
const handleCancelCommand = async () => {
if (!rpcConnectionConfig || !effectiveSession?.sessionId || !activeCommandId) {
return;
}
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.JVMCancelDiagnosticCommand !== "function") {
setError("JVMCancelDiagnosticCommand 后端方法不可用");
return;
}
setLoading(true);
setError("");
try {
const result = await backendApp.JVMCancelDiagnosticCommand(
rpcConnectionConfig,
tab.id,
effectiveSession.sessionId,
activeCommandId,
);
if (result?.success === false) {
throw new Error(String(result?.message || "取消诊断命令失败"));
}
message.info("已发送取消请求");
} catch (err: any) {
setError(err?.message || "取消诊断命令失败");
} finally {
setLoading(false);
}
};
const handleCommandEditorMount: OnMount = (editor, monaco) => {
monaco.editor.setTheme(darkMode ? "transparent-dark" : "transparent-light");
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => {
void handleExecuteCommand();
});
if (!jvmDiagnosticLanguageRegistered) {
jvmDiagnosticLanguageRegistered = true;
monaco.languages.register({ id: JVM_DIAGNOSTIC_EDITOR_LANGUAGE });
}
if (!jvmDiagnosticCompletionRegistered) {
jvmDiagnosticCompletionRegistered = true;
monaco.languages.registerCompletionItemProvider(
JVM_DIAGNOSTIC_EDITOR_LANGUAGE,
{
triggerCharacters: [" ", "-", ".", "@", "'", "\"", "{", "/"],
provideCompletionItems: (model: any, position: any) => {
const textBeforeCursor = model.getValueInRange(
new monaco.Range(1, 1, position.lineNumber, position.column),
);
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
const suggestions = resolveJVMDiagnosticCompletionItems(
textBeforeCursor,
).map((item, index) => ({
label: item.label,
kind:
item.scope === "command"
? monaco.languages.CompletionItemKind.Keyword
: item.isSnippet
? monaco.languages.CompletionItemKind.Snippet
: monaco.languages.CompletionItemKind.Value,
insertText:
item.scope === "command"
? `${item.insertText} `
: item.insertText,
insertTextRules: item.isSnippet
? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet
: undefined,
detail: item.detail,
documentation: item.documentation,
range,
sortText: `${item.scope === "command" ? "0" : "1"}-${String(index).padStart(3, "0")}`,
command:
item.scope === "command"
? { id: "editor.action.triggerSuggest" }
: undefined,
}));
return { suggestions };
},
},
);
}
};
if (!connection) {
return <Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />;
}
return (
<div
style={{ padding: 20, display: "grid", gap: 16, height: "100%" }}
data-jvm-diagnostic-console="true"
>
<Card>
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<Space size={8} wrap>
<Text strong>{connection.name}</Text>
<Tag color="blue">{diagnosticTransport}</Tag>
{effectiveSession ? <Tag color="green"></Tag> : <Tag></Tag>}
{commandRunning ? <Tag color="processing"></Tag> : null}
</Space>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
AI
</Paragraph>
<Space wrap>
<Button size="small" onClick={() => void handleProbe()} loading={loading}>
</Button>
<Button
size="small"
type="primary"
onClick={() => void handleStartSession()}
loading={loading}
>
</Button>
<Button
size="small"
type="primary"
onClick={() => void handleExecuteCommand()}
loading={commandRunning}
>
</Button>
<Button
size="small"
danger
disabled={!commandRunning || !effectiveSession?.sessionId || !activeCommandId}
onClick={() => void handleCancelCommand()}
loading={loading && commandRunning}
>
</Button>
<Button size="small" onClick={() => clearOutput(tab.id)}>
</Button>
<Button size="small" onClick={() => void loadAuditRecords()} loading={historyLoading}>
</Button>
</Space>
{error ? <Alert type="error" showIcon message={error} /> : null}
{capabilities.length ? (
<Space size={8} wrap>
{capabilities.map((item) => (
<Tag key={item.transport} color="processing">
{item.transport}
</Tag>
))}
</Space>
) : null}
</Space>
</Card>
<Card title="命令模板">
<JVMCommandPresetBar
onSelectPreset={(preset) =>
setDraft(tab.id, {
command: preset.command,
reason: preset.description,
source: "manual",
})
}
/>
</Card>
<Card title="命令输入">
<div style={{ display: "grid", gap: 12 }}>
<Editor
height={180}
language={JVM_DIAGNOSTIC_EDITOR_LANGUAGE}
theme={darkMode ? "transparent-dark" : "transparent-light"}
value={draft.command}
onMount={handleCommandEditorMount}
options={{
minimap: { enabled: false },
fontSize: 13,
automaticLayout: true,
scrollBeyondLastLine: false,
wordWrap: "on",
quickSuggestions: true,
suggestOnTriggerCharacters: true,
lineNumbers: "off",
folding: false,
glyphMargin: false,
}}
onChange={(value) =>
setDraft(tab.id, {
command: value || "",
source: "manual",
})
}
/>
<Input
value={draft.reason || ""}
placeholder="输入诊断原因,便于审计和 AI 上下文理解"
onChange={(event) => setDraft(tab.id, { reason: event.target.value })}
/>
</div>
</Card>
<div
style={{
display: "grid",
gap: 16,
gridTemplateColumns: "minmax(0, 2fr) minmax(320px, 1fr)",
alignItems: "start",
}}
>
<Card title="输出面板">
<JVMDiagnosticOutput chunks={chunks} />
</Card>
<Card title="会话与历史">
<JVMDiagnosticHistory session={effectiveSession} records={records} />
</Card>
</div>
</div>
);
};
export default JVMDiagnosticConsole;

View File

@@ -1,65 +1,223 @@
import React, { useMemo } from 'react';
import { Card, Descriptions, Empty, Space, Tag, Typography } from 'antd';
import React, { useEffect, useMemo, useState } from "react";
import {
Alert,
Card,
Descriptions,
Empty,
Skeleton,
Space,
Tag,
Typography,
} from "antd";
import { useStore } from '../store';
import type { SavedConnection, TabData } from '../types';
import JVMModeBadge from './jvm/JVMModeBadge';
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";
const { Paragraph, 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 providerMode = tab.providerMode || connection?.config.jvm?.preferredMode || 'jmx';
const connection = useStore((state) =>
state.connections.find((item) => item.id === tab.connectionId),
);
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 '';
return "";
}
const endpoint = connection.config.jvm.endpoint;
if (!endpoint.enabled && !endpoint.baseUrl) {
return '';
return "";
}
return endpoint.baseUrl || '已启用';
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 }} />;
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;
return (
<div style={{ padding: 20, display: 'grid', gap: 16 }}>
<div style={{ padding: 20, display: "grid", gap: 16 }}>
<Card>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Space direction="vertical" size={12} style={{ width: "100%" }}>
<Space size={12} wrap>
<JVMModeBadge mode={providerMode} />
<Tag color={readOnly ? 'blue' : 'red'}>{readOnly ? '只读连接' : '可写连接'}</Tag>
<Tag>{connection.config.jvm?.environment || 'dev'}</Tag>
<Tag color={readOnly ? "blue" : "red"}>
{readOnly ? "只读连接" : "可写连接"}
</Tag>
<Tag>{connection.config.jvm?.environment || "dev"}</Tag>
</Space>
<Paragraph style={{ marginBottom: 0 }}>
<Text strong>{connection.name}</Text>
<Text type="secondary"> · {connection.config.host}:{connection.config.port}</Text>
<Text type="secondary">
{" "}
· {connection.config.host}:{connection.config.port}
</Text>
</Paragraph>
</Space>
</Card>
<Card title="连接摘要">
<Descriptions column={1} size="small" labelStyle={{ width: 120 }}>
<Descriptions.Item label="当前模式">{providerMode}</Descriptions.Item>
<Descriptions.Item label="允许模式">{allowedModes.length > 0 ? allowedModes.join(', ') : 'jmx'}</Descriptions.Item>
<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="资源浏览">{'通过侧边栏展开模式节点后懒加载'}</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="模式能力">
{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>
</div>
);
};

View File

@@ -0,0 +1,114 @@
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('height:100%');
expect(markup).toContain('overflow-y:auto');
});
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)');
});
});

View File

@@ -1,9 +1,26 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Card, Descriptions, Empty, Input, Skeleton, Space, Tag, Typography } from 'antd';
import { FileSearchOutlined, ReloadOutlined, RobotOutlined } from '@ant-design/icons';
import React, { useEffect, useMemo, useState } from "react";
import Editor from "@monaco-editor/react";
import {
Alert,
Button,
Card,
Descriptions,
Empty,
Input,
Skeleton,
Space,
Tag,
Typography,
} from "antd";
import {
FileSearchOutlined,
ReloadOutlined,
RobotOutlined,
} from "@ant-design/icons";
import { useStore } from '../store';
import { useStore } from "../store";
import type {
JVMActionDefinition,
JVMApplyResult,
JVMChangePreview,
JVMChangeRequest,
@@ -11,28 +28,39 @@ import type {
JVMValueSnapshot,
SavedConnection,
TabData,
} from '../types';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
} from "../types";
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
import {
buildJVMChangeDraftFromAIPlan,
buildJVMAIPlanPrompt,
matchesJVMAIPlanTargetTab,
type JVMAIChangeDraft,
type JVMAIChangePlan,
} from '../utils/jvmAiPlan';
import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation';
import JVMModeBadge from './jvm/JVMModeBadge';
import JVMChangePreviewModal from './jvm/JVMChangePreviewModal';
} from "../utils/jvmAiPlan";
import {
estimateJVMResourceEditorHeight,
formatJVMActionDisplayText,
formatJVMActionSummary,
resolveJVMActionDisplay,
resolveJVMValueEditorLanguage,
} from "../utils/jvmResourcePresentation";
import { buildJVMTabTitle } from "../utils/jvmRuntimePresentation";
import JVMModeBadge from "./jvm/JVMModeBadge";
import JVMChangePreviewModal from "./jvm/JVMChangePreviewModal";
const { Paragraph, Text } = Typography;
const DESCRIPTION_STYLES = { label: { width: 120 } } as const;
const { TextArea } = Input;
const DEFAULT_PAYLOAD_TEXT = '{\n \n}';
const DEFAULT_PAYLOAD_TEXT = "{\n \n}";
type JVMResourceBrowserProps = {
tab: TabData;
};
const buildJVMRuntimeConfig = (connection: SavedConnection, providerMode: string) => {
const buildJVMRuntimeConfig = (
connection: SavedConnection,
providerMode: string,
) => {
const sourceJVM = connection.config.jvm || {};
return buildRpcConnectionConfig(connection.config, {
jvm: {
@@ -43,8 +71,15 @@ const buildJVMRuntimeConfig = (connection: SavedConnection, providerMode: string
});
};
const snapshotBlockStyle = (background: string): React.CSSProperties => ({
margin: 0,
borderRadius: 8,
background,
overflow: "auto",
});
const formatValue = (value: unknown): string => {
if (typeof value === 'string') {
if (typeof value === "string") {
return value;
}
try {
@@ -58,77 +93,150 @@ const formatDraftPayload = (draft: JVMAIChangeDraft): string => {
try {
return JSON.stringify(draft.payload ?? {}, null, 2);
} catch {
return '{}';
return "{}";
}
};
const buildActionPayloadTemplate = (
definition?: JVMActionDefinition | null,
): string => {
if (definition?.payloadExample) {
try {
return JSON.stringify(definition.payloadExample, null, 2);
} catch {
return DEFAULT_PAYLOAD_TEXT;
}
}
return DEFAULT_PAYLOAD_TEXT;
};
const resolveDefaultAction = (
actions: JVMActionDefinition[] | undefined,
providerMode: "jmx" | "endpoint" | "agent",
): string => {
if (actions && actions.length > 0) {
return String(actions[0].action || "").trim() || "put";
}
if (providerMode === "jmx") {
return "set";
}
return "put";
};
const normalizePreviewResult = (value: any): JVMChangePreview | null => {
if (value && typeof value === 'object' && typeof value.allowed === 'boolean') {
if (
value &&
typeof value === "object" &&
typeof value.allowed === "boolean"
) {
return value as JVMChangePreview;
}
if (value?.data && typeof value.data.allowed === 'boolean') {
if (value?.data && typeof value.data.allowed === "boolean") {
return value.data as JVMChangePreview;
}
return null;
};
const normalizeApplyResult = (value: any): JVMApplyResult | null => {
if (value && typeof value === 'object' && typeof value.status === 'string') {
if (value && typeof value === "object" && typeof value.status === "string") {
return value as JVMApplyResult;
}
if (value?.data && typeof value.data.status === 'string') {
if (value?.data && typeof value.data.status === "string") {
return value.data as JVMApplyResult;
}
return null;
};
const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
const connection = useStore((state) => state.connections.find((item) => item.id === tab.connectionId));
const connection = useStore((state) =>
state.connections.find((item) => item.id === tab.connectionId),
);
const addTab = useStore((state) => state.addTab);
const providerMode = (tab.providerMode || connection?.config.jvm?.preferredMode || 'jmx') as 'jmx' | 'endpoint' | 'agent';
const resourcePath = String(tab.resourcePath || '').trim();
const theme = useStore((state) => state.theme);
const darkMode = theme === "dark";
const providerMode = (tab.providerMode ||
connection?.config.jvm?.preferredMode ||
"jmx") as "jmx" | "endpoint" | "agent";
const resourcePath = String(tab.resourcePath || "").trim();
const readOnly = connection?.config.jvm?.readOnly !== false;
const [loading, setLoading] = useState(true);
const [snapshot, setSnapshot] = useState<JVMValueSnapshot | null>(null);
const [error, setError] = useState('');
const [action, setAction] = useState('put');
const [reason, setReason] = useState('');
const [error, setError] = useState("");
const [action, setAction] = useState("");
const [reason, setReason] = useState("");
const [payloadText, setPayloadText] = useState(DEFAULT_PAYLOAD_TEXT);
const [draftResourceId, setDraftResourceId] = useState('');
const [draftError, setDraftError] = useState('');
const [applyMessage, setApplyMessage] = useState('');
const [draftSource, setDraftSource] = useState<"manual" | "ai-plan">(
"manual",
);
const [draftResourceId, setDraftResourceId] = useState("");
const [draftError, setDraftError] = useState("");
const [applyMessage, setApplyMessage] = useState("");
const [previewLoading, setPreviewLoading] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewResult, setPreviewResult] = useState<JVMChangePreview | null>(null);
const [previewResult, setPreviewResult] = useState<JVMChangePreview | null>(
null,
);
const [applyLoading, setApplyLoading] = useState(false);
const displayValue = useMemo(() => formatValue(snapshot?.value), [snapshot]);
const displayLanguage = useMemo(
() =>
resolveJVMValueEditorLanguage(snapshot?.format || "", snapshot?.value),
[snapshot?.format, snapshot?.value],
);
const metadataText = useMemo(
() =>
snapshot?.metadata && Object.keys(snapshot.metadata).length > 0
? JSON.stringify(snapshot.metadata, null, 2)
: "",
[snapshot?.metadata],
);
const metadataLanguage = useMemo(
() => resolveJVMValueEditorLanguage("json", snapshot?.metadata),
[snapshot?.metadata],
);
const supportedActions = useMemo(() => {
if (!Array.isArray(snapshot?.supportedActions)) {
return [] as JVMActionDefinition[];
}
return snapshot.supportedActions.filter(
(item) => !!String(item?.action || "").trim(),
);
}, [snapshot]);
const selectedActionDefinition = useMemo(
() => supportedActions.find((item) => item.action === action) || null,
[action, supportedActions],
);
const selectedActionDisplay = useMemo(
() => resolveJVMActionDisplay(selectedActionDefinition || action),
[action, selectedActionDefinition],
);
const loadSnapshot = async () => {
if (!connection) {
setLoading(false);
setSnapshot(null);
setError('连接不存在或已被删除');
setError("连接不存在或已被删除");
return;
}
if (!resourcePath) {
setLoading(false);
setSnapshot(null);
setError('资源路径为空');
setError("资源路径为空");
return;
}
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.JVMGetValue !== 'function') {
if (typeof backendApp?.JVMGetValue !== "function") {
setLoading(false);
setSnapshot(null);
setError('JVMGetValue 后端方法不可用');
setError("JVMGetValue 后端方法不可用");
return;
}
setLoading(true);
setError('');
setError("");
try {
const result = await backendApp.JVMGetValue(
buildJVMRuntimeConfig(connection, providerMode),
@@ -136,13 +244,13 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
);
if (!result?.success) {
setSnapshot(null);
setError(String(result?.message || '读取 JVM 资源失败'));
setError(String(result?.message || "读取 JVM 资源失败"));
return;
}
setSnapshot((result.data || null) as JVMValueSnapshot | null);
} catch (err: any) {
setSnapshot(null);
setError(err?.message || '读取 JVM 资源失败');
setError(err?.message || "读取 JVM 资源失败");
} finally {
setLoading(false);
}
@@ -153,16 +261,34 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
}, [connection, providerMode, resourcePath, tab.connectionId]);
useEffect(() => {
setAction('put');
setReason('');
setAction("");
setReason("");
setPayloadText(DEFAULT_PAYLOAD_TEXT);
setDraftResourceId('');
setDraftError('');
setApplyMessage('');
setDraftSource("manual");
setDraftResourceId("");
setDraftError("");
setApplyMessage("");
setPreviewOpen(false);
setPreviewResult(null);
}, [providerMode, resourcePath, tab.connectionId]);
useEffect(() => {
if (action.trim()) {
return;
}
const nextAction = resolveDefaultAction(supportedActions, providerMode);
setAction(nextAction);
const nextDefinition = supportedActions.find(
(item) => item.action === nextAction,
);
if (
String(payloadText || "").trim() === "" ||
payloadText === DEFAULT_PAYLOAD_TEXT
) {
setPayloadText(buildActionPayloadTemplate(nextDefinition));
}
}, [action, payloadText, providerMode, supportedActions]);
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent).detail as
@@ -170,7 +296,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
plan?: JVMAIChangePlan;
targetTabId?: string;
connectionId?: string;
providerMode?: JVMAIPlanContext['providerMode'];
providerMode?: JVMAIPlanContext["providerMode"];
resourcePath?: string;
}
| undefined;
@@ -180,7 +306,10 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
}
const planContext =
detail?.targetTabId && detail?.connectionId && detail?.providerMode && detail?.resourcePath
detail?.targetTabId &&
detail?.connectionId &&
detail?.providerMode &&
detail?.resourcePath
? {
tabId: detail.targetTabId,
connectionId: detail.connectionId,
@@ -190,16 +319,20 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
: undefined;
if (!planContext) {
setDraftError('AI 计划缺少来源上下文,请在目标 JVM 资源页重新生成后再应用。');
setApplyMessage('');
setDraftError(
"AI 计划缺少来源上下文,请在目标 JVM 资源页重新生成后再应用。",
);
setApplyMessage("");
setPreviewOpen(false);
setPreviewResult(null);
return;
}
if (!matchesJVMAIPlanTargetTab(tab, planContext)) {
setDraftError('当前 JVM 页签与 AI 计划的来源上下文不一致,已拒绝自动应用。');
setApplyMessage('');
setDraftError(
"当前 JVM 页签与 AI 计划的来源上下文不一致,已拒绝自动应用。",
);
setApplyMessage("");
setPreviewOpen(false);
setPreviewResult(null);
return;
@@ -209,8 +342,8 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
try {
draftFromPlan = buildJVMChangeDraftFromAIPlan(plan);
} catch (err: any) {
setDraftError(err?.message || 'AI 计划暂时无法转换为 JVM 预览草稿');
setApplyMessage('');
setDraftError(err?.message || "AI 计划暂时无法转换为 JVM 预览草稿");
setApplyMessage("");
setPreviewOpen(false);
setPreviewResult(null);
return;
@@ -220,36 +353,67 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
setAction(draftFromPlan.action);
setReason(draftFromPlan.reason);
setPayloadText(formatDraftPayload(draftFromPlan));
setDraftError('');
setApplyMessage(`已从 AI 计划填充草稿,目标资源为 ${draftFromPlan.resourceId},请先执行“预览变更”再确认写入。`);
setDraftSource(draftFromPlan.source || "ai-plan");
setDraftError("");
setApplyMessage(
`已从 AI 计划填充草稿,目标资源为 ${draftFromPlan.resourceId},请先执行“预览变更”再确认写入。`,
);
setPreviewOpen(false);
setPreviewResult(null);
};
window.addEventListener('gonavi:jvm-apply-ai-plan', handler as EventListener);
return () => window.removeEventListener('gonavi:jvm-apply-ai-plan', handler as EventListener);
window.addEventListener(
"gonavi:jvm-apply-ai-plan",
handler as EventListener,
);
return () =>
window.removeEventListener(
"gonavi:jvm-apply-ai-plan",
handler as EventListener,
);
}, [resourcePath, tab.id]);
const handleSelectAction = (
nextAction: string,
definition?: JVMActionDefinition | null,
) => {
const normalized = String(nextAction || "").trim();
setAction(normalized);
if (!normalized) {
return;
}
const currentPayload = String(payloadText || "").trim();
if (
!currentPayload ||
currentPayload === "{}" ||
payloadText === DEFAULT_PAYLOAD_TEXT
) {
setPayloadText(buildActionPayloadTemplate(definition));
}
};
const buildDraftPlan = (): JVMChangeRequest => {
const trimmedAction = String(action || '').trim() || 'put';
const trimmedReason = String(reason || '').trim();
const trimmedAction = String(action || "").trim() || "put";
const trimmedReason = String(reason || "").trim();
if (!trimmedReason) {
throw new Error('请填写变更原因');
throw new Error("请填写变更原因");
}
const rawPayload = String(payloadText || '').trim();
const rawPayload = String(payloadText || "").trim();
let payload: Record<string, any> = {};
if (rawPayload) {
const parsed = JSON.parse(rawPayload);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Payload 必须是 JSON 对象');
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Payload 必须是 JSON 对象");
}
payload = parsed as Record<string, any>;
}
const resourceId = String(draftResourceId || snapshot?.resourceId || resourcePath).trim();
const resourceId = String(
draftResourceId || snapshot?.resourceId || resourcePath,
).trim();
if (!resourceId) {
throw new Error('资源 ID 为空,无法生成变更草稿');
throw new Error("资源 ID 为空,无法生成变更草稿");
}
return {
@@ -257,6 +421,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
resourceId,
action: trimmedAction,
reason: trimmedReason,
source: draftSource,
expectedVersion: snapshot?.version || undefined,
payload,
};
@@ -269,8 +434,8 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
addTab({
id: `jvm-audit-${connection.id}-${providerMode}`,
title: buildJVMTabTitle(connection.name, 'audit', providerMode),
type: 'jvm-audit',
title: buildJVMTabTitle(connection.name, "audit", providerMode),
type: "jvm-audit",
connectionId: connection.id,
providerMode,
});
@@ -278,7 +443,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
const handleAskAIForPlan = () => {
if (!connection) {
setDraftError('连接不存在或已被删除');
setDraftError("连接不存在或已被删除");
return;
}
@@ -297,20 +462,25 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
if (wasClosed) {
store.setAIPanelVisible(true);
}
setTimeout(() => {
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } }));
}, wasClosed ? 350 : 0);
setTimeout(
() => {
window.dispatchEvent(
new CustomEvent("gonavi:ai:inject-prompt", { detail: { prompt } }),
);
},
wasClosed ? 350 : 0,
);
};
const handlePreview = async () => {
if (!connection) {
setDraftError('连接不存在或已被删除');
setDraftError("连接不存在或已被删除");
return;
}
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.JVMPreviewChange !== 'function') {
setDraftError('JVMPreviewChange 后端方法不可用');
if (typeof backendApp?.JVMPreviewChange !== "function") {
setDraftError("JVMPreviewChange 后端方法不可用");
return;
}
@@ -318,13 +488,13 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
try {
draftPlan = buildDraftPlan();
} catch (err: any) {
setDraftError(err?.message || '变更草稿不合法');
setDraftError(err?.message || "变更草稿不合法");
return;
}
setPreviewLoading(true);
setDraftError('');
setApplyMessage('');
setDraftError("");
setApplyMessage("");
try {
const result = await backendApp.JVMPreviewChange(
buildJVMRuntimeConfig(connection, providerMode),
@@ -333,7 +503,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
if (result?.success === false) {
setPreviewResult(null);
setPreviewOpen(false);
setDraftError(String(result?.message || '预览 JVM 变更失败'));
setDraftError(String(result?.message || "预览 JVM 变更失败"));
return;
}
@@ -341,7 +511,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
if (!preview) {
setPreviewResult(null);
setPreviewOpen(false);
setDraftError('预览结果格式不正确');
setDraftError("预览结果格式不正确");
return;
}
@@ -350,7 +520,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
} catch (err: any) {
setPreviewResult(null);
setPreviewOpen(false);
setDraftError(err?.message || '预览 JVM 变更失败');
setDraftError(err?.message || "预览 JVM 变更失败");
} finally {
setPreviewLoading(false);
}
@@ -358,13 +528,13 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
const handleApply = async () => {
if (!connection) {
setDraftError('连接不存在或已被删除');
setDraftError("连接不存在或已被删除");
return;
}
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.JVMApplyChange !== 'function') {
setDraftError('JVMApplyChange 后端方法不可用');
if (typeof backendApp?.JVMApplyChange !== "function") {
setDraftError("JVMApplyChange 后端方法不可用");
return;
}
@@ -372,20 +542,20 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
try {
draftPlan = buildDraftPlan();
} catch (err: any) {
setDraftError(err?.message || '变更草稿不合法');
setDraftError(err?.message || "变更草稿不合法");
return;
}
setApplyLoading(true);
setDraftError('');
setApplyMessage('');
setDraftError("");
setApplyMessage("");
try {
const result = await backendApp.JVMApplyChange(
buildJVMRuntimeConfig(connection, providerMode),
draftPlan,
);
if (result?.success === false) {
setDraftError(String(result?.message || '执行 JVM 变更失败'));
setDraftError(String(result?.message || "执行 JVM 变更失败"));
return;
}
@@ -396,41 +566,91 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
setPreviewOpen(false);
setPreviewResult(null);
setApplyMessage(applyResult?.message || result?.message || 'JVM 变更已执行');
setApplyMessage(
applyResult?.message || result?.message || "JVM 变更已执行",
);
await loadSnapshot();
} catch (err: any) {
setDraftError(err?.message || '执行 JVM 变更失败');
setDraftError(err?.message || "执行 JVM 变更失败");
} finally {
setApplyLoading(false);
}
};
if (!connection) {
return <Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />;
return (
<Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />
);
}
return (
<>
<div style={{ padding: 20, display: 'grid', gap: 16 }}>
<style>{`
.jvm-resource-browser-scroll-shell {
scrollbar-width: thin;
}
.jvm-resource-browser-scroll-shell::-webkit-scrollbar,
.jvm-resource-browser-code-block::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.jvm-resource-browser-scroll-shell::-webkit-scrollbar-thumb,
.jvm-resource-browser-code-block::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.22);
border-radius: 999px;
}
.jvm-resource-browser-scroll-shell::-webkit-scrollbar-track,
.jvm-resource-browser-code-block::-webkit-scrollbar-track {
background: transparent;
}
`}</style>
<div
className="jvm-resource-browser-scroll-shell"
data-jvm-resource-browser-scroll-shell="true"
style={{
height: "100%",
minHeight: 0,
overflowY: "auto",
overflowX: "hidden",
padding: 20,
display: "grid",
gap: 16,
alignContent: "start",
}}
>
<Card>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Space direction="vertical" size={12} style={{ width: "100%" }}>
<Space size={12} wrap>
<JVMModeBadge mode={providerMode} />
<Tag color={readOnly ? 'blue' : 'red'}>{readOnly ? '只读连接' : '可写连接'}</Tag>
<Button size="small" icon={<ReloadOutlined />} onClick={() => void loadSnapshot()}>
<Tag color={readOnly ? "blue" : "red"}>
{readOnly ? "只读连接" : "可写连接"}
</Tag>
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => void loadSnapshot()}
>
</Button>
<Button size="small" icon={<FileSearchOutlined />} onClick={handleOpenAudit}>
<Button
size="small"
icon={<FileSearchOutlined />}
onClick={handleOpenAudit}
>
</Button>
<Button size="small" icon={<RobotOutlined />} onClick={handleAskAIForPlan}>
<Button
size="small"
icon={<RobotOutlined />}
onClick={handleAskAIForPlan}
>
AI
</Button>
</Space>
<Paragraph style={{ marginBottom: 0 }}>
<Text strong>{connection.name}</Text>
</Paragraph>
<Text type="secondary">{resourcePath || '-'}</Text>
<Text type="secondary">{resourcePath || "-"}</Text>
</Space>
</Card>
@@ -438,104 +658,229 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
{loading ? (
<Skeleton active paragraph={{ rows: 6 }} />
) : (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space direction="vertical" size={16} style={{ width: "100%" }}>
{error ? <Alert type="error" showIcon message={error} /> : null}
{snapshot ? (
<>
<Descriptions column={1} size="small" labelStyle={{ width: 120 }}>
<Descriptions.Item label="资源 ID">{snapshot.resourceId || '-'}</Descriptions.Item>
<Descriptions.Item label="资源类型">{snapshot.kind || tab.resourceKind || '-'}</Descriptions.Item>
<Descriptions.Item label="格式">{snapshot.format || '-'}</Descriptions.Item>
<Descriptions.Item label="版本">{snapshot.version || '-'}</Descriptions.Item>
</Descriptions>
<pre
style={{
margin: 0,
padding: 16,
borderRadius: 8,
background: 'rgba(0, 0, 0, 0.04)',
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
<Descriptions
column={1}
size="small"
styles={DESCRIPTION_STYLES}
>
{displayValue}
</pre>
{snapshot.metadata && Object.keys(snapshot.metadata).length > 0 ? (
<pre
<Descriptions.Item label="资源 ID">
{snapshot.resourceId || "-"}
</Descriptions.Item>
<Descriptions.Item label="资源类型">
{snapshot.kind || tab.resourceKind || "-"}
</Descriptions.Item>
<Descriptions.Item label="格式">
{snapshot.format || "-"}
</Descriptions.Item>
<Descriptions.Item label="版本">
{snapshot.version || "-"}
</Descriptions.Item>
<Descriptions.Item label="可用动作">
{formatJVMActionSummary(supportedActions)}
</Descriptions.Item>
</Descriptions>
{snapshot.description ? (
<Text type="secondary">{snapshot.description}</Text>
) : null}
<div>
<Text strong style={{ display: "block", marginBottom: 8 }}>
</Text>
<div
className="jvm-resource-browser-code-block"
style={{
margin: 0,
padding: 16,
borderRadius: 8,
background: 'rgba(0, 0, 0, 0.03)',
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
...snapshotBlockStyle("rgba(0, 0, 0, 0.04)"),
height: estimateJVMResourceEditorHeight(displayValue),
}}
>
{JSON.stringify(snapshot.metadata, null, 2)}
</pre>
<Editor
height="100%"
language={displayLanguage}
theme={
darkMode ? "transparent-dark" : "transparent-light"
}
value={displayValue}
options={{
readOnly: true,
minimap: { enabled: false },
lineNumbers: "on",
wordWrap: "on",
scrollBeyondLastLine: false,
automaticLayout: true,
folding: true,
renderValidationDecorations: "off",
}}
/>
</div>
</div>
{metadataText ? (
<div>
<Text
strong
style={{ display: "block", marginBottom: 8 }}
>
</Text>
<div
className="jvm-resource-browser-code-block"
style={{
...snapshotBlockStyle("rgba(0, 0, 0, 0.03)"),
height: estimateJVMResourceEditorHeight(metadataText),
}}
>
<Editor
height="100%"
language={metadataLanguage}
theme={
darkMode ? "transparent-dark" : "transparent-light"
}
value={metadataText}
options={{
readOnly: true,
minimap: { enabled: false },
lineNumbers: "on",
wordWrap: "on",
scrollBeyondLastLine: false,
automaticLayout: true,
folding: true,
renderValidationDecorations: "off",
}}
/>
</div>
</div>
) : null}
</>
) : error ? null : <Empty description="暂无资源数据" />}
) : error ? null : (
<Empty description="暂无资源数据" />
)}
</Space>
)}
</Card>
<Card title="变更草稿">
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{readOnly ? (
<Alert
type="warning"
showIcon
message="当前连接默认只读,预览或执行可能被后端策略拒绝。"
/>
) : null}
{draftError ? <Alert type="error" showIcon message={draftError} /> : null}
{applyMessage ? <Alert type="success" showIcon message={applyMessage} /> : null}
<Descriptions column={1} size="small" labelStyle={{ width: 120 }}>
<Descriptions.Item label="资源路径">{resourcePath || '-'}</Descriptions.Item>
<Descriptions.Item label="目标资源">{draftResourceId || resourcePath || '-'}</Descriptions.Item>
<Descriptions.Item label="资源版本">{snapshot?.version || '-'}</Descriptions.Item>
</Descriptions>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Text strong>Action</Text>
<Input
value={action}
onChange={(event) => setAction(event.target.value)}
placeholder="例如 put"
maxLength={64}
/>
{!readOnly ? (
<Card title="变更草稿">
<Space direction="vertical" size={16} style={{ width: "100%" }}>
{draftError ? (
<Alert type="error" showIcon message={draftError} />
) : null}
{applyMessage ? (
<Alert type="success" showIcon message={applyMessage} />
) : null}
<Descriptions column={1} size="small" styles={DESCRIPTION_STYLES}>
<Descriptions.Item label="资源路径">
{resourcePath || "-"}
</Descriptions.Item>
<Descriptions.Item label="目标资源">
{draftResourceId || resourcePath || "-"}
</Descriptions.Item>
<Descriptions.Item label="资源版本">
{snapshot?.version || "-"}
</Descriptions.Item>
<Descriptions.Item label="草稿来源">
{draftSource === "ai-plan" ? "AI 辅助草稿" : "手工编辑"}
</Descriptions.Item>
</Descriptions>
{supportedActions.length > 0 ? (
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<Text strong></Text>
<Space size={8} wrap>
{supportedActions.map((item) => (
<Button
key={item.action}
size="small"
type={action === item.action ? "primary" : "default"}
danger={item.dangerous}
onClick={() => handleSelectAction(item.action, item)}
>
{resolveJVMActionDisplay(item).label}
</Button>
))}
</Space>
{selectedActionDisplay.description ? (
<Text type="secondary">
{selectedActionDisplay.description}
</Text>
) : null}
{selectedActionDefinition?.payloadFields?.length ? (
<Text type="secondary">
Payload
{selectedActionDefinition.payloadFields
.map(
(field) =>
`${field.name}${field.required ? "(必填)" : ""}`,
)
.join("、")}
</Text>
) : null}
</Space>
) : null}
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<Text strong></Text>
<Input
value={action}
onChange={(event) =>
handleSelectAction(
event.target.value,
selectedActionDefinition,
)
}
placeholder={
providerMode === "jmx"
? "例如 set 或 invoke"
: "例如 put / clear / evict"
}
maxLength={64}
/>
{action ? (
<Text type="secondary">
{formatJVMActionDisplayText(selectedActionDisplay)}
</Text>
) : null}
</Space>
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<Text strong></Text>
<Input
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="填写本次 JVM 资源变更原因"
maxLength={200}
/>
</Space>
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<Text strong>Payload(JSON)</Text>
<Text type="secondary">
JSON 使 payload
{selectedActionDefinition?.payloadExample
? " 已按当前动作填充推荐模板。"
: ""}
</Text>
<TextArea
value={payloadText}
onChange={(event) => setPayloadText(event.target.value)}
autoSize={{ minRows: 8, maxRows: 18 }}
spellCheck={false}
/>
</Space>
<Space size={12} wrap>
<Button
type="primary"
loading={previewLoading}
onClick={() => void handlePreview()}
>
</Button>
<Button icon={<RobotOutlined />} onClick={handleAskAIForPlan}>
AI
</Button>
</Space>
</Space>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Text strong></Text>
<Input
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="填写本次 JVM 资源变更原因"
maxLength={200}
/>
</Space>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Text strong>Payload(JSON)</Text>
<Text type="secondary"> JSON 使 payload</Text>
<TextArea
value={payloadText}
onChange={(event) => setPayloadText(event.target.value)}
autoSize={{ minRows: 8, maxRows: 18 }}
spellCheck={false}
/>
</Space>
<Space size={12} wrap>
<Button type="primary" loading={previewLoading} onClick={() => void handlePreview()}>
</Button>
<Button icon={<RobotOutlined />} onClick={handleAskAIForPlan}>
AI
</Button>
</Space>
</Space>
</Card>
</Card>
) : null}
</div>
<JVMChangePreviewModal

View File

@@ -62,7 +62,7 @@ interface TreeNode {
children?: TreeNode[];
icon?: React.ReactNode;
dataRef?: any;
type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource';
type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource' | 'jvm-diagnostic';
}
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
@@ -991,7 +991,21 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
},
isLeaf: capability.canBrowse !== true,
}));
setTreeData(origin => updateTreeData(origin, node.key, modeNodes));
const diagnosticNode: TreeNode[] =
conn.config.jvm?.diagnostic?.enabled
? [{
title: '诊断增强',
key: `${conn.id}-jvm-diagnostic`,
icon: <DashboardOutlined />,
type: 'jvm-diagnostic',
dataRef: {
...conn,
diagnosticTransport: conn.config.jvm?.diagnostic?.transport || 'agent-bridge',
},
isLeaf: true,
}]
: [];
setTreeData(origin => updateTreeData(origin, node.key, [...modeNodes, ...diagnosticNode]));
} else {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
setLoadedKeys(prev => prev.filter(k => k !== node.key));
@@ -1549,7 +1563,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
} else if (type === 'table') {
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
} else if (type === 'jvm-mode' || type === 'jvm-resource') {
} else if (type === 'jvm-mode' || type === 'jvm-resource' || type === 'jvm-diagnostic') {
setActiveContext({ connectionId: dataRef.id, dbName: '' });
} else if (type === 'view' || type === 'db-trigger' || type === 'routine') {
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
@@ -1597,7 +1611,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const { type, dataRef, key: nodeKey } = node;
if (type === 'connection') setActiveContext({ connectionId: nodeKey, dbName: '' });
else if (type === 'database') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
else if (type === 'jvm-mode' || type === 'jvm-resource') setActiveContext({ connectionId: dataRef.id, dbName: '' });
else if (type === 'jvm-mode' || type === 'jvm-resource' || type === 'jvm-diagnostic') setActiveContext({ connectionId: dataRef.id, dbName: '' });
else if (type === 'table' || type === 'view' || type === 'db-trigger' || type === 'routine') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
else if (type === 'saved-query') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
else if (type === 'external-sql-root' || type === 'external-sql-directory' || type === 'external-sql-folder' || type === 'external-sql-file') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
@@ -1686,6 +1700,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const conn = (connections.find((item) => item.id === id) || node.dataRef) as SavedConnection;
openJVMResourceTab(conn, providerMode, resourcePath, resourceKind);
return;
} else if (node.type === 'jvm-diagnostic') {
const conn = (connections.find((item) => item.id === node.dataRef.id) || node.dataRef) as SavedConnection;
openJVMDiagnosticTab(conn);
return;
}
const key = node.key;
@@ -2518,6 +2536,16 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
});
};
const openJVMDiagnosticTab = (conn: SavedConnection) => {
const transport = conn.config.jvm?.diagnostic?.transport || 'agent-bridge';
addTab({
id: `jvm-diagnostic-${conn.id}`,
title: buildJVMTabTitle(conn.name, 'diagnostic', transport),
type: 'jvm-diagnostic',
connectionId: conn.id,
});
};
const getConnectionNodeRef = (connRef: any) => {
const latestConn = connections.find(c => c.id === connRef.id);
return { key: connRef.id, dataRef: latestConn || connRef };

View File

@@ -19,6 +19,7 @@ import TableOverview from './TableOverview';
import JVMOverview from './JVMOverview';
import JVMResourceBrowser from './JVMResourceBrowser';
import JVMAuditViewer from './JVMAuditViewer';
import JVMDiagnosticConsole from './JVMDiagnosticConsole';
import type { TabData } from '../types';
import { buildTabDisplayTitle } from '../utils/tabDisplay';
@@ -212,6 +213,8 @@ const TabManager: React.FC = () => {
content = <JVMResourceBrowser tab={tab} />;
} else if (tab.type === 'jvm-audit') {
content = <JVMAuditViewer tab={tab} />;
} else if (tab.type === 'jvm-diagnostic') {
content = <JVMDiagnosticConsole tab={tab} />;
}
const menuItems: MenuProps['items'] = [

View File

@@ -11,6 +11,10 @@ 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';
// 🔧 性能优化:将 ReactMarkdown 包装为 Memo 组件并提取固定的 plugins
const remarkPlugins = [remarkGfm];
@@ -576,6 +580,12 @@ export const AIMessageBubble: React.FC<AIMessageBubbleProps> = React.memo(({ msg
}
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;
@@ -737,6 +747,43 @@ export const AIMessageBubble: React.FC<AIMessageBubbleProps> = React.memo(({ msg
</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

@@ -1,9 +1,11 @@
import React, { useMemo } from 'react';
import { Alert, Descriptions, Modal, Space, Tag, Typography } from 'antd';
import React, { useMemo } from "react";
import { Alert, Descriptions, Modal, Space, Tag, Typography } from "antd";
import type { JVMChangePreview } from '../../types';
import type { JVMChangePreview } from "../../types";
import { formatJVMRiskLevelText } from "../../utils/jvmResourcePresentation";
const { Text } = Typography;
const DESCRIPTION_STYLES = { label: { width: 120 } } as const;
type JVMChangePreviewModalProps = {
open: boolean;
@@ -14,13 +16,13 @@ type JVMChangePreviewModalProps = {
};
const riskColorMap: Record<string, string> = {
low: 'green',
medium: 'orange',
high: 'red',
low: "green",
medium: "orange",
high: "red",
};
const formatValue = (value: unknown): string => {
if (typeof value === 'string') {
if (typeof value === "string") {
return value;
}
try {
@@ -34,10 +36,10 @@ 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',
background: "rgba(0, 0, 0, 0.04)",
overflow: "auto",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
maxHeight: 280,
};
@@ -50,9 +52,9 @@ const JVMChangePreviewModal: React.FC<JVMChangePreviewModalProps> = ({
}) => {
const summary = useMemo(() => {
if (!preview) {
return '暂无预览结果';
return "暂无预览结果";
}
return preview.summary || '预览已生成';
return preview.summary || "预览已生成";
}, [preview]);
return (
@@ -70,53 +72,96 @@ const JVMChangePreviewModal: React.FC<JVMChangePreviewModalProps> = ({
{!preview ? (
<Alert type="info" showIcon message="暂无预览结果" />
) : (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={1} size="small" labelStyle={{ width: 120 }}>
<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'}>
{preview.riskLevel || 'unknown'}
<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>}
{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">{preview.blockingReason}</Text>
<Text type="danger" style={{ whiteSpace: "pre-wrap" }}>
{preview.blockingReason}
</Text>
</Descriptions.Item>
) : null}
</Descriptions>
{!preview.allowed && preview.blockingReason ? (
<Alert type="error" showIcon message={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 strong style={{ display: "block", marginBottom: 8 }}>
</Text>
<Descriptions column={1} size="small" labelStyle={{ width: 120 }} 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
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}>{formatValue(preview.before?.value)}</pre>
<pre style={previewBlockStyle}>
{formatValue(preview.before?.value)}
</pre>
</div>
<div>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
<Text strong style={{ display: "block", marginBottom: 8 }}>
</Text>
<Descriptions column={1} size="small" labelStyle={{ width: 120 }} 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
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}>{formatValue(preview.after?.value)}</pre>
<pre style={previewBlockStyle}>
{formatValue(preview.after?.value)}
</pre>
</div>
</Space>
)}

View File

@@ -0,0 +1,52 @@
import React from "react";
import { Button, Card, Space, Tag, Typography } from "antd";
import {
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}
styles={{ body: { display: "grid", gap: 8 } }}
>
{group.items.map((preset) => (
<Space
key={preset.key}
align="start"
style={{ width: "100%", justifyContent: "space-between" }}
>
<div style={{ display: "grid", gap: 4 }}>
<Space size={8} wrap>
<Button size="small" onClick={() => onSelectPreset(preset)}>
{preset.label}
</Button>
<Tag color={resolveJVMDiagnosticRiskColor(preset.riskLevel)}>
{preset.riskLevel.toUpperCase()}
</Tag>
</Space>
<Text type="secondary">{preset.description}</Text>
<Text code>{preset.command}</Text>
</div>
</Space>
))}
</Card>
))}
</div>
);
export default JVMCommandPresetBar;

View File

@@ -0,0 +1,78 @@
import React from "react";
import { Empty, List, Tag, Typography } from "antd";
import type {
JVMDiagnosticAuditRecord,
JVMDiagnosticSessionHandle,
} from "../../types";
const { Text } = Typography;
type JVMDiagnosticHistoryProps = {
session?: JVMDiagnosticSessionHandle | null;
records?: JVMDiagnosticAuditRecord[];
};
const JVMDiagnosticHistory: React.FC<JVMDiagnosticHistoryProps> = ({
session,
records = [],
}) => (
<div style={{ display: "grid", gap: 12 }}>
<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>{session.transport}</Tag>
</div>
) : (
<Empty
description="尚未建立诊断会话"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</div>
<div style={{ display: "grid", gap: 8 }}>
<Text strong></Text>
{records.length ? (
<div style={{ maxHeight: 360, 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">{record.status}</Tag> : null}
{record.riskLevel ? <Tag color="gold">{record.riskLevel}</Tag> : null}
{record.commandType ? <Tag color="blue">{record.commandType}</Tag> : null}
{record.source ? <Tag>{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,50 @@
import React from "react";
import { Empty, List, Tag, Typography } from "antd";
import type { JVMDiagnosticEventChunk } from "../../types";
import { formatJVMDiagnosticChunkText } from "../../utils/jvmDiagnosticPresentation";
const { Text } = Typography;
type JVMDiagnosticOutputProps = {
chunks: JVMDiagnosticEventChunk[];
};
const JVMDiagnosticOutput: React.FC<JVMDiagnosticOutputProps> = ({ chunks }) => {
if (!chunks.length) {
return <Empty description="尚无诊断输出" />;
}
return (
<div style={{ maxHeight: 420, 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",
}}
>
{formatJVMDiagnosticChunkText(chunk)}
</Text>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{chunk.phase ? <Tag color="geekblue">{chunk.phase}</Tag> : null}
{chunk.event ? <Tag>{chunk.event}</Tag> : null}
{chunk.commandId ? <Tag color="blue">{chunk.commandId}</Tag> : null}
</div>
</div>
</List.Item>
)}
/>
</div>
);
};
export default JVMDiagnosticOutput;

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;
@@ -37,17 +37,110 @@ export interface JVMEndpointConfig {
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';
environment?: "dev" | "uat" | "prod";
readOnly?: boolean;
allowedModes?: Array<'jmx' | 'endpoint' | 'agent'>;
preferredMode?: 'jmx' | 'endpoint' | 'agent';
allowedModes?: Array<"jmx" | "endpoint" | "agent">;
preferredMode?: "jmx" | "endpoint" | "agent";
jmx?: JVMJMXConfig;
endpoint?: JVMEndpointConfig;
agent?: JVMAgentConfig;
diagnostic?: JVMDiagnosticConfig;
}
export interface JVMCapability {
mode: 'jmx' | 'endpoint' | 'agent';
mode: "jmx" | "endpoint" | "agent";
canBrowse: boolean;
canWrite: boolean;
canPreview: boolean;
@@ -61,19 +154,38 @@ export interface JVMResourceSummary {
kind: string;
name: string;
path: string;
providerMode: 'jmx' | 'endpoint' | 'agent';
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>;
}
@@ -81,17 +193,18 @@ export interface JVMChangePreview {
allowed: boolean;
requiresConfirmation?: boolean;
summary: string;
riskLevel: 'low' | 'medium' | 'high';
riskLevel: "low" | "medium" | "high";
blockingReason?: string;
before: JVMValueSnapshot;
after: JVMValueSnapshot;
}
export interface JVMChangeRequest {
providerMode: 'jmx' | 'endpoint' | 'agent';
providerMode: "jmx" | "endpoint" | "agent";
resourceId: string;
action: string;
reason: string;
source?: "manual" | "ai-plan";
expectedVersion?: string;
payload?: Record<string, any>;
}
@@ -109,6 +222,7 @@ export interface JVMAuditRecord {
resourceId: string;
action: string;
reason: string;
source?: string;
result: string;
}
@@ -122,7 +236,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;
@@ -137,7 +251,7 @@ export interface ConnectionConfig {
redisDB?: number; // Redis database index (0-15)
uri?: string; // Connection URI for copy/paste
hosts?: string[]; // Multi-host addresses: host:port
topology?: 'single' | 'replica' | 'cluster';
topology?: "single" | "replica" | "cluster";
mysqlReplicaUser?: string;
mysqlReplicaPassword?: string;
replicaSet?: string;
@@ -174,8 +288,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 {
@@ -226,14 +340,28 @@ 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' | 'jvm-overview' | 'jvm-resource' | 'jvm-audit';
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";
connectionId: string;
dbName?: string;
tableName?: string;
query?: string;
initialTab?: string;
readOnly?: boolean;
providerMode?: 'jmx' | 'endpoint' | 'agent';
providerMode?: "jmx" | "endpoint" | "agent";
resourcePath?: string;
resourceKind?: string;
redisDB?: number; // Redis database index for redis tabs
@@ -247,10 +375,16 @@ export interface TabData {
export interface JVMAIPlanContext {
tabId: string;
connectionId: string;
providerMode: 'jmx' | 'endpoint' | 'agent';
providerMode: "jmx" | "endpoint" | "agent";
resourcePath: string;
}
export interface JVMDiagnosticPlanContext {
tabId: string;
connectionId: string;
transport: JVMDiagnosticTransport;
}
export interface DatabaseNode {
title: string;
key: string;
@@ -297,7 +431,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;
@@ -320,9 +454,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;
@@ -355,11 +489,16 @@ 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;
@@ -372,40 +511,50 @@ export interface AIChatMessage {
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';
| "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 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';
| "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';
| "open_connection"
| "open_proxy_settings"
| "open_ai_settings"
| "retry_update"
| "view_details";
export interface SecurityUpdateSummary {
total: number;
@@ -431,7 +580,7 @@ export interface SecurityUpdateStatus {
schemaVersion?: number;
migrationId?: string;
overallStatus: SecurityUpdateOverallStatus;
sourceType?: 'current_app_saved_config';
sourceType?: "current_app_saved_config";
reminderVisible?: boolean;
canStart?: boolean;
canPostpone?: boolean;

View File

@@ -4,6 +4,7 @@ import {
getStoredSecretPlaceholder,
normalizeConnectionSecretErrorMessage,
resolveConnectionTestFailureFeedback,
summarizeConnectionTestFailureMessage,
} from './connectionModalPresentation';
describe('connectionModalPresentation', () => {
@@ -33,14 +34,14 @@ describe('connectionModalPresentation', () => {
expect(normalizeConnectionSecretErrorMessage('连接测试超时')).toBe('连接测试超时');
});
it('shows a toast-worthy failure message for saved-secret lookup errors during connection tests', () => {
it('keeps saved-secret lookup errors inside the modal instead of raising a global toast', () => {
expect(resolveConnectionTestFailureFeedback({
kind: 'runtime',
reason: 'saved connection not found: conn-1',
fallback: '连接失败',
})).toEqual({
message: '测试失败: 未找到当前连接对应的已保存密文,请重新填写密码并保存后再试',
shouldToast: true,
shouldToast: false,
});
});
@@ -54,4 +55,10 @@ describe('connectionModalPresentation', () => {
shouldToast: false,
});
});
});
it('uses only the first line for connection failure toast summaries', () => {
expect(summarizeConnectionTestFailureMessage(`测试失败: 当前端口不是 JMX 远程管理端口\n建议请改填 JMX 端口\n技术细节raw error`)).toBe(
'测试失败: 当前端口不是 JMX 远程管理端口',
);
});
});

View File

@@ -50,6 +50,18 @@ export const normalizeConnectionSecretErrorMessage = (
return text;
};
export const summarizeConnectionTestFailureMessage = (
value: unknown,
fallback = '',
): string => {
const text = normalizeConnectionSecretErrorMessage(value, fallback);
const [firstLine] = text
.split(/\r?\n/)
.map((item) => item.trim())
.filter((item) => item !== '');
return firstLine || text;
};
export const resolveConnectionTestFailureFeedback = ({
kind,
reason,
@@ -68,7 +80,7 @@ export const resolveConnectionTestFailureFeedback = ({
return {
message: `测试失败: ${normalizeConnectionSecretErrorMessage(reason, fallback)}`,
shouldToast: true,
shouldToast: false,
};
};

View File

@@ -53,6 +53,7 @@ describe('buildJVMChangeDraftFromAIPlan', () => {
resourceId: 'orders/user:1',
action: 'put',
reason: '修复缓存脏值',
source: 'ai-plan',
payload: {
status: 'ACTIVE',
},
@@ -69,6 +70,7 @@ describe('buildJVMChangeDraftFromAIPlan', () => {
resourceId: '/cache/orders',
action: 'clear',
reason: '受控清理',
source: 'ai-plan',
payload: {},
});
});
@@ -79,7 +81,24 @@ describe('buildJVMChangeDraftFromAIPlan', () => {
);
expect(plan).not.toBeNull();
expect(() => buildJVMChangeDraftFromAIPlan(plan!)).toThrow('当前 JVM 预览仅支持 JSON 对象作为变更 payload');
expect(() => buildJVMChangeDraftFromAIPlan(plan!)).toThrow('当前 JVM 预览要求 payload 仍然是 JSON 对象');
});
it('keeps generic action for managed bean payload updates', () => {
const plan = extractJVMChangePlan(
'```json\n{"targetType":"attribute","selector":{"resourcePath":"jmx://java.lang/type=Memory/attribute/Verbose"},"action":"set","payload":{"format":"json","value":{"value":true}},"reason":"开启诊断日志"}\n```',
);
expect(plan).not.toBeNull();
expect(buildJVMChangeDraftFromAIPlan(plan!)).toEqual({
resourceId: 'jmx://java.lang/type=Memory/attribute/Verbose',
action: 'set',
reason: '开启诊断日志',
source: 'ai-plan',
payload: {
value: true,
},
});
});
});

View File

@@ -1,13 +1,13 @@
import type { JVMChangeRequest, JVMAIPlanContext, JVMValueSnapshot, TabData } from '../types';
import type { JVMActionDefinition, JVMChangeRequest, JVMAIPlanContext, JVMValueSnapshot, TabData } from '../types';
export type JVMAIChangePlan = {
targetType: 'cacheEntry' | 'managedBean';
targetType: 'cacheEntry' | 'managedBean' | 'attribute' | 'operation';
selector: {
namespace?: string;
key?: string;
resourcePath?: string;
};
action: 'updateValue' | 'evict' | 'clear';
action: string;
payload?: {
format: 'json' | 'text';
value: unknown;
@@ -15,7 +15,7 @@ export type JVMAIChangePlan = {
reason: string;
};
export type JVMAIChangeDraft = Pick<JVMChangeRequest, 'resourceId' | 'action' | 'reason' | 'payload'>;
export type JVMAIChangeDraft = Pick<JVMChangeRequest, 'resourceId' | 'action' | 'reason' | 'source' | 'payload'>;
type JVMAIPlanPromptContext = {
connectionName: string;
@@ -28,8 +28,7 @@ type JVMAIPlanPromptContext = {
};
const planFencePattern = /```json\s*([\s\S]*?)```/gi;
const allowedTargetTypes = new Set<JVMAIChangePlan['targetType']>(['cacheEntry', 'managedBean']);
const allowedActions = new Set<JVMAIChangePlan['action']>(['updateValue', 'evict', 'clear']);
const allowedTargetTypes = new Set<JVMAIChangePlan['targetType']>(['cacheEntry', 'managedBean', 'attribute', 'operation']);
const allowedPayloadFormats = new Set<NonNullable<JVMAIChangePlan['payload']>['format']>(['json', 'text']);
const asTrimmedString = (value: unknown): string => String(value ?? '').trim();
@@ -90,7 +89,7 @@ const normalizePlan = (value: unknown): JVMAIChangePlan | null => {
const selector = normalizeSelector(value.selector);
const payload = normalizePayload(value.payload);
if (!allowedTargetTypes.has(targetType) || !allowedActions.has(action) || !reason || !selector) {
if (!allowedTargetTypes.has(targetType) || !action || !reason || !selector) {
return null;
}
@@ -189,27 +188,74 @@ export const buildJVMChangeDraftFromAIPlan = (plan: JVMAIChangePlan): JVMAIChang
throw new Error('AI 计划缺少变更原因');
}
const action = asTrimmedString(plan.action);
if (!action) {
throw new Error('AI 计划缺少可执行 action');
}
if (plan.action === 'updateValue') {
const value = plan.payload?.value;
if (plan.payload?.format !== 'json' || !isRecord(value)) {
throw new Error('当前 JVM 预览仅支持 JSON 对象作为变更 payload');
throw new Error('当前 JVM 预览要求 payload 仍然是 JSON 对象');
}
return {
resourceId,
action: 'put',
reason,
source: 'ai-plan',
payload: value as Record<string, any>,
};
}
const payloadValue = plan.payload?.value;
if (plan.payload && plan.payload.format === 'json') {
if (!isRecord(payloadValue)) {
throw new Error('当前 JVM 预览要求 payload 仍然是 JSON 对象');
}
return {
resourceId,
action,
reason,
source: 'ai-plan',
payload: payloadValue as Record<string, any>,
};
}
if (plan.payload && plan.payload.format === 'text') {
return {
resourceId,
action,
reason,
source: 'ai-plan',
payload: {
value: payloadValue == null ? '' : String(payloadValue),
},
};
}
return {
resourceId,
action: plan.action,
action,
reason,
source: 'ai-plan',
payload: {},
};
};
const formatSupportedActions = (actions?: JVMActionDefinition[]): string => {
if (!actions || actions.length === 0) {
return '当前资源未声明支持动作。若要生成计划,请仅在你能从快照内容中明确推断时给出 action并保持 payload 为 JSON 对象。';
}
return actions
.map((item) => {
const payloadFields = Array.isArray(item.payloadFields) && item.payloadFields.length > 0
? `payload 字段:${item.payloadFields.map((field) => `${field.name}${field.required ? '(required)' : ''}`).join('、')}`
: '';
return `- ${item.action}${item.label ? ` (${item.label})` : ''}${item.description ? `${item.description}` : ''}${payloadFields}`;
})
.join('\n');
};
export const buildJVMAIPlanPrompt = ({
connectionName,
host,
@@ -222,6 +268,7 @@ export const buildJVMAIPlanPrompt = ({
const normalizedPath = asTrimmedString(resourcePath) || '(未提供资源路径)';
const snapshotFormat = asTrimmedString(snapshot?.format) || 'json';
const environmentLabel = asTrimmedString(environment) || 'unknown';
const supportedActionsText = formatSupportedActions(snapshot?.supportedActions);
return [
'请分析下面这个 JVM 资源,并生成一个可用于 GoNavi “预览变更” 的结构化修改计划。',
@@ -238,12 +285,15 @@ export const buildJVMAIPlanPrompt = ({
formatSnapshotValue(snapshot),
'```',
'',
'当前资源支持动作:',
supportedActionsText,
'',
'输出要求:',
'1. 可以先给一小段分析,但必须包含且只包含一个 ```json 代码块。',
'2. 代码块里的 JSON 字段必须严格是targetType、selector、action、payload、reason。',
`3. selector.resourcePath 优先使用当前资源路径 ${normalizedPath},不要凭空编造其他路径。`,
'4. action 只能使用 updateValue、evict、clear 之一。',
'5. 当前 MVP 只支持 JSON 对象变更:如果 action=updateValue则 payload 必须是 {"format":"json","value":{...}},且 value 必须是 JSON 对象evict/clear 时 payload 可以省略。',
'4. action 优先从“当前资源支持动作”里选择;如果当前资源未声明支持动作,才允许基于快照内容推断。',
'5. payload 只能使用 JSON 对象包装,不要输出脚本、命令或原始二进制。若需要纯文本值,也请包装成 {"format":"text","value":"..."}。',
'6. 不要声称已经执行修改,也不要输出脚本或命令。',
'',
'JSON 示例:',
@@ -254,7 +304,7 @@ export const buildJVMAIPlanPrompt = ({
selector: {
resourcePath: normalizedPath,
},
action: 'updateValue',
action: 'put',
payload: {
format: 'json',
value: {

View File

@@ -1,119 +1,183 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from "vitest";
import {
buildDefaultJVMConnectionValues,
buildJVMConnectionConfig,
hasUnsupportedJVMDiagnosticTransport,
hasUnsupportedJVMEditableModes,
normalizeEditableJVMModes,
resolveEditableJVMModeSelection,
} from './jvmConnectionConfig';
} from "./jvmConnectionConfig";
describe('jvmConnectionConfig', () => {
it('defaults to readonly jmx mode', () => {
describe("jvmConnectionConfig", () => {
it("defaults to readonly jmx mode", () => {
const values = buildDefaultJVMConnectionValues();
expect(values.type).toBe('jvm');
expect(values.type).toBe("jvm");
expect(values.jvmReadOnly).toBe(true);
expect(values.jvmAllowedModes).toEqual(['jmx']);
expect(values.jvmPreferredMode).toBe('jmx');
expect(values.jvmAllowedModes).toEqual(["jmx"]);
expect(values.jvmPreferredMode).toBe("jmx");
expect(values.jvmDiagnosticEnabled).toBe(false);
expect(values.jvmDiagnosticTransport).toBe("agent-bridge");
expect(values.jvmDiagnosticAllowObserveCommands).toBe(true);
expect(values.jvmDiagnosticAllowTraceCommands).toBe(false);
expect(values.jvmDiagnosticAllowMutatingCommands).toBe(false);
expect(values.jvmDiagnosticTimeoutSeconds).toBe(15);
});
it('builds nested jvm config payload', () => {
it("builds nested jvm config payload", () => {
const config = buildJVMConnectionConfig({
name: 'Orders JVM',
type: 'jvm',
host: 'orders.internal',
name: "Orders JVM",
type: "jvm",
host: "orders.internal",
port: 9010,
jvmReadOnly: true,
jvmAllowedModes: ['jmx', 'endpoint'],
jvmPreferredMode: 'endpoint',
jvmEnvironment: 'prod',
jvmAllowedModes: ["jmx", "endpoint", "agent"],
jvmPreferredMode: "agent",
jvmEnvironment: "prod",
jvmEndpointEnabled: true,
jvmEndpointBaseUrl: 'https://orders.internal/manage/jvm',
jvmEndpointApiKey: 'token-1',
jvmEndpointBaseUrl: "https://orders.internal/manage/jvm",
jvmEndpointApiKey: "token-1",
jvmAgentEnabled: true,
jvmAgentBaseUrl: "http://127.0.0.1:19090/gonavi/agent/jvm",
jvmAgentApiKey: "agent-token",
timeout: 45,
jvmDiagnosticEnabled: true,
jvmDiagnosticTransport: "arthas-tunnel",
jvmDiagnosticBaseUrl: "https://orders.internal/diag",
jvmDiagnosticTargetId: "orders-01",
jvmDiagnosticApiKey: "diag-token",
jvmDiagnosticAllowObserveCommands: true,
jvmDiagnosticAllowTraceCommands: true,
jvmDiagnosticAllowMutatingCommands: false,
jvmDiagnosticTimeoutSeconds: 18,
});
expect(config.jvm?.preferredMode).toBe("agent");
expect(config.jvm?.endpoint?.baseUrl).toBe(
"https://orders.internal/manage/jvm",
);
expect(config.jvm?.agent?.baseUrl).toBe(
"http://127.0.0.1:19090/gonavi/agent/jvm",
);
expect(config.jvm?.diagnostic).toEqual({
enabled: true,
transport: "arthas-tunnel",
baseUrl: "https://orders.internal/diag",
targetId: "orders-01",
apiKey: "diag-token",
allowObserveCommands: true,
allowTraceCommands: true,
allowMutatingCommands: false,
timeoutSeconds: 18,
});
expect(config.jvm?.preferredMode).toBe('endpoint');
expect(config.jvm?.endpoint?.baseUrl).toBe('https://orders.internal/manage/jvm');
});
it('normalizes allowed modes and falls back preferred mode to first allowed mode', () => {
it("normalizes allowed modes and falls back preferred mode to first allowed mode", () => {
const config = buildJVMConnectionConfig({
host: 'cache.internal',
host: "cache.internal",
port: 9010,
jvmAllowedModes: [' Endpoint ', 'invalid', 'JMX', 'endpoint'],
jvmPreferredMode: 'AGENT',
jvmAllowedModes: [" Endpoint ", "invalid", "JMX", "endpoint"],
jvmPreferredMode: "AGENT",
});
expect(config.jvm?.allowedModes).toEqual(['endpoint', 'jmx']);
expect(config.jvm?.preferredMode).toBe('endpoint');
expect(config.jvm?.allowedModes).toEqual(["endpoint", "jmx"]);
expect(config.jvm?.preferredMode).toBe("endpoint");
expect(config.jvm?.jmx?.enabled).toBe(true);
});
it('normalizes environment and port defaults when input is invalid', () => {
it("normalizes environment and port defaults when input is invalid", () => {
const config = buildJVMConnectionConfig({
host: 'orders.internal',
host: "orders.internal",
port: 0,
jvmJmxPort: '',
jvmEnvironment: ' PROD ',
jvmJmxPort: "",
jvmEnvironment: " PROD ",
jvmReadOnly: false,
jvmAllowedModes: ['JMX'],
jvmPreferredMode: 'jmx',
jvmAllowedModes: ["JMX"],
jvmPreferredMode: "jmx",
});
expect(config.port).toBe(9010);
expect(config.jvm?.jmx?.port).toBe(9010);
expect(config.jvm?.environment).toBe('prod');
expect(config.jvm?.environment).toBe("prod");
expect(config.jvm?.readOnly).toBe(false);
});
it('keeps the visible timeout as the source of truth for endpoint probing', () => {
it("keeps endpoint timeout aligned to the visible connection timeout", () => {
const config = buildJVMConnectionConfig({
host: 'orders.internal',
host: "orders.internal",
port: 9010,
timeout: 45,
jvmEndpointTimeoutSeconds: 30,
jvmAllowedModes: ['endpoint'],
jvmPreferredMode: 'endpoint',
jvmAllowedModes: ["endpoint"],
jvmPreferredMode: "endpoint",
jvmEndpointEnabled: true,
jvmEndpointBaseUrl: 'https://orders.internal/manage/jvm',
jvmEndpointBaseUrl: "https://orders.internal/manage/jvm",
jvmDiagnosticEnabled: true,
jvmDiagnosticTransport: "arthas-tunnel",
jvmDiagnosticBaseUrl: "https://orders.internal/diag",
jvmDiagnosticTargetId: "orders-01",
jvmDiagnosticApiKey: "diag-token",
jvmDiagnosticAllowObserveCommands: true,
jvmDiagnosticAllowTraceCommands: true,
jvmDiagnosticAllowMutatingCommands: false,
jvmDiagnosticTimeoutSeconds: 18,
});
expect(config.timeout).toBe(45);
expect(config.jvm?.endpoint?.timeoutSeconds).toBe(45);
expect(config.jvm?.diagnostic?.timeoutSeconds).toBe(18);
});
it('normalizes editable JVM modes to the supported form subset', () => {
expect(normalizeEditableJVMModes([' endpoint ', 'agent', 'JMX', 'endpoint'])).toEqual(['endpoint', 'jmx']);
it("detects unsupported diagnostic transport without silently accepting it", () => {
expect(hasUnsupportedJVMDiagnosticTransport("legacy-bridge")).toBe(true);
expect(hasUnsupportedJVMDiagnosticTransport("agent-bridge")).toBe(false);
expect(hasUnsupportedJVMDiagnosticTransport("")).toBe(false);
});
it('detects unsupported editable JVM modes without downgrading them silently', () => {
expect(hasUnsupportedJVMEditableModes({
allowedModes: ['agent', 'jmx'],
preferredMode: 'agent',
})).toBe(true);
expect(hasUnsupportedJVMEditableModes({
allowedModes: ['endpoint', 'jmx'],
preferredMode: 'agent',
})).toBe(true);
expect(hasUnsupportedJVMEditableModes({
allowedModes: ['endpoint', 'jmx'],
preferredMode: 'endpoint',
})).toBe(false);
it("normalizes editable JVM modes to the supported form subset", () => {
expect(
normalizeEditableJVMModes([" endpoint ", "agent", "JMX", "endpoint"]),
).toEqual(["endpoint", "agent", "jmx"]);
});
it('preserves preferred mode when rebuilding editable mode selection from stored config', () => {
expect(resolveEditableJVMModeSelection({
allowedModes: [],
preferredMode: 'agent',
})).toEqual({
allowedModes: ['agent'],
preferredMode: 'agent',
it("detects unsupported editable JVM modes without downgrading them silently", () => {
expect(
hasUnsupportedJVMEditableModes({
allowedModes: ["agent", "jmx"],
preferredMode: "agent",
}),
).toBe(false);
expect(
hasUnsupportedJVMEditableModes({
allowedModes: ["endpoint", "jmx"],
preferredMode: "otel",
}),
).toBe(true);
expect(
hasUnsupportedJVMEditableModes({
allowedModes: ["endpoint", "jmx"],
preferredMode: "endpoint",
}),
).toBe(false);
});
it("preserves preferred mode when rebuilding editable mode selection from stored config", () => {
expect(
resolveEditableJVMModeSelection({
allowedModes: [],
preferredMode: "agent",
}),
).toEqual({
allowedModes: ["agent"],
preferredMode: "agent",
});
expect(resolveEditableJVMModeSelection({
allowedModes: ['endpoint', 'jmx'],
preferredMode: 'agent',
})).toEqual({
allowedModes: ['endpoint', 'jmx'],
preferredMode: 'agent',
expect(
resolveEditableJVMModeSelection({
allowedModes: ["endpoint", "jmx"],
preferredMode: "agent",
}),
).toEqual({
allowedModes: ["endpoint", "jmx"],
preferredMode: "agent",
});
});
});

View File

@@ -1,31 +1,40 @@
import type { ConnectionConfig } from '../types';
import type { ConnectionConfig } from "../types";
const DEFAULT_JMX_PORT = 9010;
const DEFAULT_TIMEOUT_SECONDS = 30;
const DEFAULT_ENVIRONMENT = 'dev';
const JVM_MODES = ['jmx', 'endpoint', 'agent'] as const;
export const JVM_EDITABLE_MODES = ['jmx', 'endpoint'] as const;
const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15;
const DEFAULT_ENVIRONMENT = "dev";
const JVM_MODES = ["jmx", "endpoint", "agent"] as const;
export const JVM_EDITABLE_MODES = ["jmx", "endpoint", "agent"] as const;
const JVM_DIAGNOSTIC_TRANSPORTS = ["agent-bridge", "arthas-tunnel"] as const;
type JVMMode = typeof JVM_MODES[number];
type JVMEditableMode = typeof JVM_EDITABLE_MODES[number];
type JVMEnvironment = 'dev' | 'uat' | 'prod';
type JVMMode = (typeof JVM_MODES)[number];
type JVMEditableMode = (typeof JVM_EDITABLE_MODES)[number];
type JVMDiagnosticTransport = (typeof JVM_DIAGNOSTIC_TRANSPORTS)[number];
type JVMEnvironment = "dev" | "uat" | "prod";
type JVMConnectionFormValues = Record<string, unknown>;
const isJVMMode = (value: string): value is JVMMode => JVM_MODES.includes(value as JVMMode);
const isJVMEditableMode = (value: string): value is JVMEditableMode => JVM_EDITABLE_MODES.includes(value as JVMEditableMode);
const isJVMMode = (value: string): value is JVMMode =>
JVM_MODES.includes(value as JVMMode);
const isJVMEditableMode = (value: string): value is JVMEditableMode =>
JVM_EDITABLE_MODES.includes(value as JVMEditableMode);
const isJVMDiagnosticTransport = (
value: string,
): value is JVMDiagnosticTransport =>
JVM_DIAGNOSTIC_TRANSPORTS.includes(value as JVMDiagnosticTransport);
const toStringValue = (value: unknown): string => {
if (typeof value === 'string') {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === 'number' || typeof value === 'boolean') {
if (typeof value === "number" || typeof value === "boolean") {
return String(value).trim();
}
return '';
return "";
};
const toInteger = (value: unknown, fallback: number): number => {
if (value === undefined || value === null || value === '') {
if (value === undefined || value === null || value === "") {
return fallback;
}
const parsed = Number(value);
@@ -38,7 +47,7 @@ const toInteger = (value: unknown, fallback: number): number => {
const normalizeModes = (value: unknown): JVMMode[] => {
if (!Array.isArray(value)) {
return ['jmx'];
return ["jmx"];
}
const result: JVMMode[] = [];
@@ -51,12 +60,14 @@ const normalizeModes = (value: unknown): JVMMode[] => {
seen.add(mode);
result.push(mode);
}
return result.length > 0 ? result : ['jmx'];
return result.length > 0 ? result : ["jmx"];
};
export const normalizeEditableJVMModes = (value: unknown): JVMEditableMode[] => {
export const normalizeEditableJVMModes = (
value: unknown,
): JVMEditableMode[] => {
if (!Array.isArray(value)) {
return ['jmx'];
return ["jmx"];
}
const result: JVMEditableMode[] = [];
@@ -69,7 +80,7 @@ export const normalizeEditableJVMModes = (value: unknown): JVMEditableMode[] =>
seen.add(mode);
result.push(mode);
}
return result.length > 0 ? result : ['jmx'];
return result.length > 0 ? result : ["jmx"];
};
export const hasUnsupportedJVMEditableModes = ({
@@ -80,12 +91,23 @@ export const hasUnsupportedJVMEditableModes = ({
preferredMode: unknown;
}): boolean => {
const allowed = Array.isArray(allowedModes)
? allowedModes.map((item) => toStringValue(item).toLowerCase()).filter((item) => item !== '')
? allowedModes
.map((item) => toStringValue(item).toLowerCase())
.filter((item) => item !== "")
: [];
const preferred = toStringValue(preferredMode).toLowerCase();
return allowed.some((mode) => !isJVMEditableMode(mode))
|| (preferred !== '' && !isJVMEditableMode(preferred));
return (
allowed.some((mode) => !isJVMEditableMode(mode)) ||
(preferred !== "" && !isJVMEditableMode(preferred))
);
};
export const hasUnsupportedJVMDiagnosticTransport = (
value: unknown,
): boolean => {
const transport = toStringValue(value).toLowerCase();
return transport !== "" && !isJVMDiagnosticTransport(transport);
};
export const resolveEditableJVMModeSelection = ({
@@ -96,12 +118,17 @@ export const resolveEditableJVMModeSelection = ({
preferredMode: unknown;
}): { allowedModes: string[]; preferredMode: string } => {
const normalizedAllowedModes = Array.isArray(allowedModes)
? allowedModes.map((item) => toStringValue(item).toLowerCase()).filter((item) => item !== '')
? allowedModes
.map((item) => toStringValue(item).toLowerCase())
.filter((item) => item !== "")
: [];
const normalizedPreferredMode = toStringValue(preferredMode).toLowerCase();
const resolvedAllowedModes = normalizedAllowedModes.length > 0
? Array.from(new Set(normalizedAllowedModes))
: (normalizedPreferredMode ? [normalizedPreferredMode] : ['jmx']);
const resolvedAllowedModes =
normalizedAllowedModes.length > 0
? Array.from(new Set(normalizedAllowedModes))
: normalizedPreferredMode
? [normalizedPreferredMode]
: ["jmx"];
return {
allowedModes: resolvedAllowedModes,
@@ -109,7 +136,10 @@ export const resolveEditableJVMModeSelection = ({
};
};
const normalizePreferredMode = (value: unknown, allowedModes: JVMMode[]): JVMMode => {
const normalizePreferredMode = (
value: unknown,
allowedModes: JVMMode[],
): JVMMode => {
const preferred = toStringValue(value).toLowerCase();
if (isJVMMode(preferred) && allowedModes.includes(preferred)) {
return preferred;
@@ -119,46 +149,80 @@ const normalizePreferredMode = (value: unknown, allowedModes: JVMMode[]): JVMMod
const normalizeEnvironment = (value: unknown): JVMEnvironment => {
const env = toStringValue(value).toLowerCase();
if (env === 'uat' || env === 'prod') {
if (env === "uat" || env === "prod") {
return env;
}
return DEFAULT_ENVIRONMENT;
};
const normalizeReadOnly = (value: unknown): boolean => {
if (typeof value === 'boolean') {
if (typeof value === "boolean") {
return value;
}
return true;
};
const normalizeDiagnosticTransport = (
value: unknown,
): JVMDiagnosticTransport => {
const transport = toStringValue(value).toLowerCase();
if (isJVMDiagnosticTransport(transport)) {
return transport;
}
return "agent-bridge";
};
export const buildDefaultJVMConnectionValues = () => ({
type: 'jvm',
host: 'localhost',
type: "jvm",
host: "localhost",
port: DEFAULT_JMX_PORT,
jvmReadOnly: true,
jvmAllowedModes: ['jmx'],
jvmPreferredMode: 'jmx',
jvmAllowedModes: ["jmx"],
jvmPreferredMode: "jmx",
jvmEnvironment: DEFAULT_ENVIRONMENT,
jvmEndpointEnabled: false,
jvmEndpointBaseUrl: '',
jvmEndpointApiKey: '',
jvmEndpointBaseUrl: "",
jvmEndpointApiKey: "",
jvmAgentEnabled: false,
jvmAgentBaseUrl: "",
jvmAgentApiKey: "",
jvmDiagnosticEnabled: false,
jvmDiagnosticTransport: "agent-bridge",
jvmDiagnosticBaseUrl: "",
jvmDiagnosticTargetId: "",
jvmDiagnosticApiKey: "",
jvmDiagnosticAllowObserveCommands: true,
jvmDiagnosticAllowTraceCommands: false,
jvmDiagnosticAllowMutatingCommands: false,
jvmDiagnosticTimeoutSeconds: DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS,
});
export const buildJVMConnectionConfig = (values: JVMConnectionFormValues): ConnectionConfig => {
export const buildJVMConnectionConfig = (
values: JVMConnectionFormValues,
): ConnectionConfig => {
const allowedModes = normalizeModes(values.jvmAllowedModes);
const preferredMode = normalizePreferredMode(values.jvmPreferredMode, allowedModes);
const preferredMode = normalizePreferredMode(
values.jvmPreferredMode,
allowedModes,
);
const port = toInteger(values.port, DEFAULT_JMX_PORT);
const timeout = values.timeout === undefined || values.timeout === null || values.timeout === ''
? toInteger(values.jvmEndpointTimeoutSeconds, DEFAULT_TIMEOUT_SECONDS)
: toInteger(values.timeout, DEFAULT_TIMEOUT_SECONDS);
const timeout =
values.timeout === undefined ||
values.timeout === null ||
values.timeout === ""
? toInteger(values.jvmEndpointTimeoutSeconds, DEFAULT_TIMEOUT_SECONDS)
: toInteger(values.timeout, DEFAULT_TIMEOUT_SECONDS);
const diagnosticTimeout = toInteger(
values.jvmDiagnosticTimeoutSeconds,
DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS,
);
return {
type: 'jvm',
type: "jvm",
host: toStringValue(values.host),
port,
user: '',
password: '',
user: "",
password: "",
timeout,
jvm: {
environment: normalizeEnvironment(values.jvmEnvironment),
@@ -166,7 +230,7 @@ export const buildJVMConnectionConfig = (values: JVMConnectionFormValues): Conne
allowedModes,
preferredMode,
jmx: {
enabled: allowedModes.includes('jmx'),
enabled: allowedModes.includes("jmx"),
host: toStringValue(values.jvmJmxHost) || toStringValue(values.host),
port: toInteger(values.jvmJmxPort, port),
username: toStringValue(values.jvmJmxUsername),
@@ -178,6 +242,24 @@ export const buildJVMConnectionConfig = (values: JVMConnectionFormValues): Conne
apiKey: toStringValue(values.jvmEndpointApiKey),
timeoutSeconds: timeout,
},
agent: {
enabled: values.jvmAgentEnabled === true,
baseUrl: toStringValue(values.jvmAgentBaseUrl),
apiKey: toStringValue(values.jvmAgentApiKey),
timeoutSeconds: timeout,
},
diagnostic: {
enabled: values.jvmDiagnosticEnabled === true,
transport: normalizeDiagnosticTransport(values.jvmDiagnosticTransport),
baseUrl: toStringValue(values.jvmDiagnosticBaseUrl),
targetId: toStringValue(values.jvmDiagnosticTargetId),
apiKey: toStringValue(values.jvmDiagnosticApiKey),
allowObserveCommands: values.jvmDiagnosticAllowObserveCommands !== false,
allowTraceCommands: values.jvmDiagnosticAllowTraceCommands === true,
allowMutatingCommands:
values.jvmDiagnosticAllowMutatingCommands === true,
timeoutSeconds: diagnosticTimeout,
},
},
};
};

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
resolveJVMDiagnosticCompletionItems,
resolveJVMDiagnosticCompletionMode,
} from "./jvmDiagnosticCompletion";
describe("jvmDiagnosticCompletion", () => {
it("suggests command keywords when typing the first token", () => {
const items = resolveJVMDiagnosticCompletionItems("t");
expect(items.some((item) => item.label === "thread")).toBe(true);
expect(items.some((item) => item.label === "trace")).toBe(true);
});
it("switches to argument mode after the command head", () => {
expect(resolveJVMDiagnosticCompletionMode("thread -")).toEqual({
head: "thread",
mode: "argument",
search: "-",
});
});
it("returns command-specific snippets for trace style commands", () => {
const items = resolveJVMDiagnosticCompletionItems("watch ");
expect(items.some((item) => item.label === "watch 模板")).toBe(true);
expect(items.some((item) => item.label === "展开层级 -x 2")).toBe(true);
expect(items.every((item) => item.scope === "argument")).toBe(true);
});
it("supports multiline commands by using the current line before cursor", () => {
const items = resolveJVMDiagnosticCompletionItems(
"thread -n 5\nclas",
);
expect(items.some((item) => item.label === "classloader")).toBe(true);
expect(items.some((item) => item.label === "watch")).toBe(false);
});
it("falls back to command suggestions for unknown heads", () => {
const items = resolveJVMDiagnosticCompletionItems("unknown ");
expect(items.some((item) => item.label === "dashboard")).toBe(true);
expect(items.some((item) => item.label === "thread")).toBe(true);
});
});

View File

@@ -0,0 +1,485 @@
import { JVM_DIAGNOSTIC_COMMAND_PRESETS } from "./jvmDiagnosticPresentation";
export type JVMDiagnosticCompletionMode = "command" | "argument";
export interface JVMDiagnosticCompletionState {
mode: JVMDiagnosticCompletionMode;
head: string;
search: string;
}
export interface JVMDiagnosticCompletionItem {
label: string;
insertText: string;
detail: string;
documentation?: string;
scope: JVMDiagnosticCompletionMode;
isSnippet?: boolean;
}
type DiagnosticCommandDefinition = {
head: string;
detail: string;
documentation: string;
};
const BASE_COMMAND_DEFINITIONS: DiagnosticCommandDefinition[] = [
{
head: "dashboard",
detail: "观察类命令",
documentation: "查看 JVM 运行总览。",
},
{
head: "thread",
detail: "观察类命令",
documentation: "查看热点线程、线程栈和阻塞线程。",
},
{
head: "sc",
detail: "观察类命令",
documentation: "搜索匹配类信息。",
},
{
head: "sm",
detail: "观察类命令",
documentation: "查看类的方法签名。",
},
{
head: "jad",
detail: "观察类命令",
documentation: "反编译指定类。",
},
{
head: "sysprop",
detail: "观察类命令",
documentation: "查看系统属性。",
},
{
head: "sysenv",
detail: "观察类命令",
documentation: "查看环境变量。",
},
{
head: "classloader",
detail: "观察类命令",
documentation: "查看类加载器信息。",
},
{
head: "trace",
detail: "跟踪类命令",
documentation: "跟踪方法调用耗时路径。",
},
{
head: "watch",
detail: "跟踪类命令",
documentation: "观察入参、返回值或异常。",
},
{
head: "stack",
detail: "跟踪类命令",
documentation: "输出方法调用栈。",
},
{
head: "monitor",
detail: "跟踪类命令",
documentation: "周期性统计方法调用。",
},
{
head: "tt",
detail: "跟踪类命令",
documentation: "方法时光隧道,记录和回放调用。",
},
{
head: "ognl",
detail: "高风险命令",
documentation: "执行 OGNL 表达式,默认需要额外授权。",
},
{
head: "vmtool",
detail: "高风险命令",
documentation: "直接操作 JVM 对象或执行 VMTool 动作。",
},
{
head: "redefine",
detail: "高风险命令",
documentation: "重新定义类字节码。",
},
{
head: "retransform",
detail: "高风险命令",
documentation: "重新触发类转换。",
},
{
head: "stop",
detail: "控制命令",
documentation: "停止当前后台任务。",
},
];
const buildBaseCommandItems = (): JVMDiagnosticCompletionItem[] => {
const itemsByHead = new Map<string, JVMDiagnosticCompletionItem>();
BASE_COMMAND_DEFINITIONS.forEach((item) => {
itemsByHead.set(item.head, {
label: item.head,
insertText: item.head,
detail: item.detail,
documentation: item.documentation,
scope: "command",
});
});
JVM_DIAGNOSTIC_COMMAND_PRESETS.forEach((item) => {
const head = item.command.split(/\s+/, 1)[0]?.trim().toLowerCase() || item.label;
if (itemsByHead.has(head)) {
return;
}
itemsByHead.set(head, {
label: head,
insertText: head,
detail: `${item.category} 命令`,
documentation: item.description,
scope: "command",
});
});
return Array.from(itemsByHead.values());
};
const BASE_COMMAND_ITEMS = buildBaseCommandItems();
const ARGUMENT_ITEMS_BY_HEAD: Record<string, JVMDiagnosticCompletionItem[]> = {
dashboard: [
{
label: "dashboard",
insertText: "",
detail: "直接执行",
documentation: "查看当前 JVM 运行总览。",
scope: "argument",
},
],
thread: [
{
label: "繁忙线程 TOP N (-n)",
insertText: "-n ${1:5}",
detail: "线程参数",
documentation: "查看 CPU 最繁忙的前 N 个线程。",
scope: "argument",
isSnippet: true,
},
{
label: "阻塞线程 (-b)",
insertText: "-b",
detail: "线程参数",
documentation: "查找当前阻塞其他线程的线程。",
scope: "argument",
},
{
label: "指定线程 ID",
insertText: "${1:1}",
detail: "线程参数",
documentation: "查看指定线程的详细栈信息。",
scope: "argument",
isSnippet: true,
},
],
sc: [
{
label: "类匹配模板",
insertText: "${1:com.foo.*}",
detail: "类搜索模板",
documentation: "按类名模式搜索。",
scope: "argument",
isSnippet: true,
},
{
label: "详细模式 (-d)",
insertText: "-d ${1:com.foo.OrderService}",
detail: "类搜索模板",
documentation: "输出类的详细信息。",
scope: "argument",
isSnippet: true,
},
],
sm: [
{
label: "方法签名模板",
insertText: "${1:com.foo.OrderService} ${2:submitOrder}",
detail: "方法搜索模板",
documentation: "查看类的方法签名。",
scope: "argument",
isSnippet: true,
},
{
label: "详细模式 (-d)",
insertText: "-d ${1:com.foo.OrderService} ${2:submitOrder}",
detail: "方法搜索模板",
documentation: "输出方法详细签名。",
scope: "argument",
isSnippet: true,
},
],
jad: [
{
label: "反编译模板",
insertText: "${1:com.foo.OrderService}",
detail: "反编译模板",
documentation: "反编译指定类。",
scope: "argument",
isSnippet: true,
},
],
sysprop: [
{
label: "查看属性",
insertText: "${1:java.version}",
detail: "系统属性模板",
documentation: "读取指定系统属性。",
scope: "argument",
isSnippet: true,
},
],
sysenv: [
{
label: "查看环境变量",
insertText: "${1:JAVA_HOME}",
detail: "环境变量模板",
documentation: "读取指定环境变量。",
scope: "argument",
isSnippet: true,
},
],
classloader: [
{
label: "树形视图 (-t)",
insertText: "-t",
detail: "类加载器模板",
documentation: "输出类加载器树形结构。",
scope: "argument",
},
{
label: "全部 URL 统计 (--url-stat)",
insertText: "--url-stat",
detail: "类加载器模板",
documentation: "查看类加载器 URL 统计。",
scope: "argument",
},
{
label: "指定类加载器 Hash",
insertText: "${1:19469ea2}",
detail: "类加载器模板",
documentation: "查看指定类加载器详情。",
scope: "argument",
isSnippet: true,
},
],
trace: [
{
label: "trace 模板",
insertText: "${1:com.foo.OrderService} ${2:submitOrder} '${3:#cost > 100}'",
detail: "跟踪模板",
documentation: "跟踪慢方法调用链路。",
scope: "argument",
isSnippet: true,
},
{
label: "条件过滤 '#cost > 100'",
insertText: "'${1:#cost > 100}'",
detail: "跟踪参数",
documentation: "追加 trace 条件表达式。",
scope: "argument",
isSnippet: true,
},
],
watch: [
{
label: "watch 模板",
insertText:
"${1:com.foo.OrderService} ${2:submitOrder} '${3:{params,returnObj}}' -x ${4:2}",
detail: "观察模板",
documentation: "观察入参、返回值或异常。",
scope: "argument",
isSnippet: true,
},
{
label: "展开层级 -x 2",
insertText: "-x ${1:2}",
detail: "观察参数",
documentation: "设置对象展开层级。",
scope: "argument",
isSnippet: true,
},
],
stack: [
{
label: "stack 模板",
insertText: "${1:com.foo.OrderService} ${2:submitOrder} '${3:#cost > 100}'",
detail: "调用栈模板",
documentation: "输出方法调用栈。",
scope: "argument",
isSnippet: true,
},
],
monitor: [
{
label: "monitor 模板",
insertText: "${1:com.foo.OrderService} ${2:submitOrder} -c ${3:5}",
detail: "监控模板",
documentation: "按周期统计方法调用情况。",
scope: "argument",
isSnippet: true,
},
],
tt: [
{
label: "tt 录制模板",
insertText: "-t ${1:com.foo.OrderService} ${2:submitOrder}",
detail: "时光隧道模板",
documentation: "录制指定方法调用。",
scope: "argument",
isSnippet: true,
},
{
label: "查看记录列表 (-l)",
insertText: "-l",
detail: "时光隧道模板",
documentation: "查看当前录制列表。",
scope: "argument",
},
{
label: "回放记录 (-i)",
insertText: "-i ${1:1000} -p",
detail: "时光隧道模板",
documentation: "查看指定记录详情。",
scope: "argument",
isSnippet: true,
},
],
ognl: [
{
label: "ognl 模板",
insertText: "'${1:@java.lang.System@getProperty(\"user.dir\")}'",
detail: "高风险模板",
documentation: "执行 OGNL 表达式,高风险命令默认受策略限制。",
scope: "argument",
isSnippet: true,
},
],
vmtool: [
{
label: "vmtool getInstances",
insertText:
"--action getInstances --className ${1:com.foo.OrderService} --limit ${2:10}",
detail: "高风险模板",
documentation: "获取指定类实例,高风险命令默认受策略限制。",
scope: "argument",
isSnippet: true,
},
],
redefine: [
{
label: "redefine 模板",
insertText: "${1:/tmp/OrderService.class}",
detail: "高风险模板",
documentation: "重新定义类字节码文件路径。",
scope: "argument",
isSnippet: true,
},
],
retransform: [
{
label: "retransform 模板",
insertText: "${1:com.foo.OrderService}",
detail: "高风险模板",
documentation: "重新转换指定类。",
scope: "argument",
isSnippet: true,
},
],
stop: [
{
label: "stop",
insertText: "",
detail: "控制命令",
documentation: "停止当前后台任务。",
scope: "argument",
},
],
};
const COMMAND_HEAD_SET = new Set(
BASE_COMMAND_ITEMS.map((item) => item.label.toLowerCase()),
);
const normalizeSearchText = (value: string): string =>
String(value || "").trim().toLowerCase();
const resolveCurrentLine = (textBeforeCursor: string): string =>
String(textBeforeCursor || "").split(/\r?\n/).pop() || "";
const matchesSearch = (
item: JVMDiagnosticCompletionItem,
search: string,
): boolean => {
if (!search) {
return true;
}
const normalizedSearch = normalizeSearchText(search);
const candidates = [item.label, item.insertText, item.detail];
return candidates.some((candidate) =>
String(candidate || "").toLowerCase().includes(normalizedSearch),
);
};
export const resolveJVMDiagnosticCompletionMode = (
textBeforeCursor: string,
): JVMDiagnosticCompletionState => {
const currentLine = resolveCurrentLine(textBeforeCursor);
const normalizedLine = currentLine.replace(/^\s+/, "");
if (!normalizedLine) {
return {
mode: "command",
head: "",
search: "",
};
}
const head = normalizedLine.split(/\s+/, 1)[0]?.toLowerCase() || "";
const hasWhitespaceAfterHead = /\s/.test(normalizedLine);
if (!hasWhitespaceAfterHead) {
return {
mode: "command",
head,
search: head,
};
}
const search = (normalizedLine.match(/([^\s]*)$/)?.[1] || "").toLowerCase();
if (COMMAND_HEAD_SET.has(head)) {
return {
mode: "argument",
head,
search,
};
}
return {
mode: "command",
head: "",
search,
};
};
export const resolveJVMDiagnosticCompletionItems = (
textBeforeCursor: string,
): JVMDiagnosticCompletionItem[] => {
const state = resolveJVMDiagnosticCompletionMode(textBeforeCursor);
const source =
state.mode === "argument" && state.head
? ARGUMENT_ITEMS_BY_HEAD[state.head] || []
: BASE_COMMAND_ITEMS;
return source.filter((item) => matchesSearch(item, state.search));
};

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import {
parseJVMDiagnosticPlan,
resolveJVMDiagnosticPlanTargetTabId,
} from "./jvmDiagnosticPlan";
describe("jvmDiagnosticPlan", () => {
it("parses arthas-style diagnostic plan payload", () => {
const plan = parseJVMDiagnosticPlan(`{
"intent": "trace_slow_method",
"transport": "agent-bridge",
"command": "trace com.foo.OrderService submitOrder '#cost > 100'",
"riskLevel": "medium",
"reason": "定位慢调用"
}`);
expect(plan?.command).toContain("trace com.foo.OrderService");
expect(plan?.riskLevel).toBe("medium");
});
it("parses fenced json blocks mixed with analysis text", () => {
const plan = parseJVMDiagnosticPlan(
[
"建议先观察再做下一步:",
"```json",
'{"intent":"dump_threads","transport":"arthas-tunnel","command":"thread -n 5","riskLevel":"low","reason":"观察阻塞线程","expectedSignals":["Top N busy threads"]}',
"```",
].join("\n"),
);
expect(plan).toEqual({
intent: "dump_threads",
transport: "arthas-tunnel",
command: "thread -n 5",
riskLevel: "low",
reason: "观察阻塞线程",
expectedSignals: ["Top N busy threads"],
});
});
it("returns null for malformed diagnostic payload", () => {
expect(parseJVMDiagnosticPlan('{"command":1}')).toBeNull();
});
});
describe("resolveJVMDiagnosticPlanTargetTabId", () => {
it("prefers the original diagnostic tab when context still matches", () => {
expect(
resolveJVMDiagnosticPlanTargetTabId(
[
{
id: "tab-diagnostic",
title: "诊断控制台",
type: "jvm-diagnostic",
connectionId: "conn-orders",
},
],
[
{
id: "conn-orders",
config: {
type: "jvm",
host: "orders.internal",
port: 9010,
user: "",
jvm: {
diagnostic: {
transport: "agent-bridge",
},
},
},
},
],
{
tabId: "tab-diagnostic",
connectionId: "conn-orders",
transport: "agent-bridge",
},
),
).toBe("tab-diagnostic");
});
it("rejects fallback tabs whose connection transport does not match", () => {
expect(
resolveJVMDiagnosticPlanTargetTabId(
[
{
id: "tab-diagnostic",
title: "诊断控制台",
type: "jvm-diagnostic",
connectionId: "conn-orders",
},
],
[
{
id: "conn-orders",
config: {
type: "jvm",
host: "orders.internal",
port: 9010,
user: "",
jvm: {
diagnostic: {
transport: "arthas-tunnel",
},
},
},
},
],
{
tabId: "tab-missing",
connectionId: "conn-orders",
transport: "agent-bridge",
},
),
).toBe("");
});
});

View File

@@ -0,0 +1,135 @@
import type {
JVMDiagnosticPlan,
JVMDiagnosticPlanContext,
SavedConnection,
TabData,
} from "../types";
const planFencePattern = /```json\s*([\s\S]*?)```/gi;
const allowedTransports = new Set<JVMDiagnosticPlan["transport"]>([
"agent-bridge",
"arthas-tunnel",
]);
const allowedRiskLevels = new Set<JVMDiagnosticPlan["riskLevel"]>([
"low",
"medium",
"high",
]);
const asTrimmedString = (value: unknown): string => String(value ?? "").trim();
const isRecord = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value);
const normalizeTransport = (value: unknown): JVMDiagnosticPlan["transport"] => {
const transport = asTrimmedString(value) as JVMDiagnosticPlan["transport"];
return allowedTransports.has(transport) ? transport : "agent-bridge";
};
const normalizeRiskLevel = (value: unknown): JVMDiagnosticPlan["riskLevel"] => {
const riskLevel = asTrimmedString(value) as JVMDiagnosticPlan["riskLevel"];
return allowedRiskLevels.has(riskLevel) ? riskLevel : "low";
};
const normalizePlan = (value: unknown): JVMDiagnosticPlan | null => {
if (!isRecord(value)) {
return null;
}
if (typeof value.command !== "string") {
return null;
}
const command = asTrimmedString(value.command);
if (!command) {
return null;
}
const intent = asTrimmedString(value.intent) || "generic_diagnostic";
const reason = asTrimmedString(value.reason) || `AI 诊断计划:${intent}`;
return {
intent,
transport: normalizeTransport(value.transport),
command,
riskLevel: normalizeRiskLevel(value.riskLevel),
reason,
expectedSignals: Array.isArray(value.expectedSignals)
? value.expectedSignals
.map((item) => asTrimmedString(item))
.filter(Boolean)
: [],
};
};
const tryParsePlan = (content: string): JVMDiagnosticPlan | null => {
try {
return normalizePlan(JSON.parse(content));
} catch {
return null;
}
};
const resolveDiagnosticTransport = (
connection?: Pick<SavedConnection, "config">,
): JVMDiagnosticPlan["transport"] =>
normalizeTransport(connection?.config?.jvm?.diagnostic?.transport);
export const parseJVMDiagnosticPlan = (
content: string,
): JVMDiagnosticPlan | null => {
const source = String(content || "").trim();
if (!source) {
return null;
}
planFencePattern.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = planFencePattern.exec(source)) !== null) {
const parsed = tryParsePlan(match[1]);
if (parsed) {
return parsed;
}
}
return tryParsePlan(source);
};
export const matchesJVMDiagnosticPlanTargetTab = (
tab: Pick<TabData, "id" | "type" | "connectionId">,
connections: Pick<SavedConnection, "id" | "config">[],
context?: JVMDiagnosticPlanContext,
): boolean => {
if (!context || tab.type !== "jvm-diagnostic") {
return false;
}
const connection = connections.find((item) => item.id === tab.connectionId);
return (
tab.connectionId === context.connectionId &&
resolveDiagnosticTransport(connection) === normalizeTransport(context.transport)
);
};
export const resolveJVMDiagnosticPlanTargetTabId = (
tabs: TabData[],
connections: Pick<SavedConnection, "id" | "config">[],
context?: JVMDiagnosticPlanContext,
): string => {
if (!context) {
return "";
}
const exactMatch = tabs.find(
(tab) =>
tab.id === context.tabId &&
matchesJVMDiagnosticPlanTargetTab(tab, connections, context),
);
if (exactMatch) {
return exactMatch.id;
}
const fallbackMatch = tabs.find((tab) =>
matchesJVMDiagnosticPlanTargetTab(tab, connections, context),
);
return fallbackMatch?.id || "";
};

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
formatJVMDiagnosticChunkText,
groupJVMDiagnosticPresets,
resolveJVMDiagnosticRiskColor,
} from "./jvmDiagnosticPresentation";
describe("jvmDiagnosticPresentation", () => {
it("groups presets by category in a stable order", () => {
const groups = groupJVMDiagnosticPresets();
expect(groups.map((group) => group.label)).toEqual([
"观察类命令",
"跟踪类命令",
"高风险命令",
]);
expect(groups[0].items.some((item) => item.label === "thread")).toBe(true);
});
it("formats chunk text with phase prefix when content exists", () => {
expect(
formatJVMDiagnosticChunkText({
sessionId: "sess-1",
phase: "running",
content: "thread -n 5",
}),
).toBe("running: thread -n 5");
});
it("maps risk levels to tag colors", () => {
expect(resolveJVMDiagnosticRiskColor("low")).toBe("green");
expect(resolveJVMDiagnosticRiskColor("medium")).toBe("gold");
expect(resolveJVMDiagnosticRiskColor("high")).toBe("red");
});
});

View File

@@ -0,0 +1,105 @@
import type { JVMDiagnosticEventChunk } from "../types";
export type JVMDiagnosticPresetCategory = "observe" | "trace" | "mutating";
export interface JVMDiagnosticCommandPreset {
key: string;
label: string;
category: JVMDiagnosticPresetCategory;
command: string;
description: string;
riskLevel: "low" | "medium" | "high";
}
export const JVM_DIAGNOSTIC_COMMAND_PRESETS: JVMDiagnosticCommandPreset[] = [
{
key: "thread-top",
label: "thread",
category: "observe",
command: "thread -n 5",
description: "查看最繁忙线程,快速定位阻塞或高 CPU 线程。",
riskLevel: "low",
},
{
key: "dashboard",
label: "dashboard",
category: "observe",
command: "dashboard",
description: "查看 JVM 运行总览。",
riskLevel: "low",
},
{
key: "trace-slow-method",
label: "trace",
category: "trace",
command: "trace com.foo.OrderService submitOrder '#cost > 100'",
description: "跟踪慢方法调用路径。",
riskLevel: "medium",
},
{
key: "watch-return",
label: "watch",
category: "trace",
command: "watch com.foo.OrderService submitOrder '{params,returnObj}' -x 2",
description: "观察入参与返回值。",
riskLevel: "medium",
},
{
key: "ognl-sample",
label: "ognl",
category: "mutating",
command: "ognl '@java.lang.System@getProperty(\"user.dir\")'",
description: "高风险表达式命令,默认只作示意。",
riskLevel: "high",
},
];
const CATEGORY_LABELS: Record<JVMDiagnosticPresetCategory, string> = {
observe: "观察类命令",
trace: "跟踪类命令",
mutating: "高风险命令",
};
const RISK_COLORS: Record<"low" | "medium" | "high", string> = {
low: "green",
medium: "gold",
high: "red",
};
export const formatJVMDiagnosticPresetCategory = (
category: JVMDiagnosticPresetCategory,
): string => CATEGORY_LABELS[category];
export const resolveJVMDiagnosticRiskColor = (
riskLevel: "low" | "medium" | "high",
): string => RISK_COLORS[riskLevel];
export const groupJVMDiagnosticPresets = (
presets: JVMDiagnosticCommandPreset[] = JVM_DIAGNOSTIC_COMMAND_PRESETS,
): Array<{
category: JVMDiagnosticPresetCategory;
label: string;
items: JVMDiagnosticCommandPreset[];
}> =>
(["observe", "trace", "mutating"] as const).map((category) => ({
category,
label: formatJVMDiagnosticPresetCategory(category),
items: presets.filter((item) => item.category === category),
}));
export const formatJVMDiagnosticChunkText = (
chunk: JVMDiagnosticEventChunk,
): string => {
const phase = String(chunk.phase || chunk.event || "").trim();
const content = String(chunk.content || "").trim();
if (!phase && !content) {
return "空事件";
}
if (!phase) {
return content;
}
if (!content) {
return phase;
}
return `${phase}: ${content}`;
};

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
estimateJVMResourceEditorHeight,
formatJVMAuditResultLabel,
formatJVMActionSummary,
formatJVMRiskLevelText,
resolveJVMAuditResultColor,
resolveJVMActionDisplay,
resolveJVMValueEditorLanguage,
} from "./jvmResourcePresentation";
describe("jvmResourcePresentation", () => {
it("provides a localized fallback label for built-in JVM actions", () => {
expect(resolveJVMActionDisplay({ action: "set" })).toMatchObject({
action: "set",
label: "设置属性",
});
});
it("keeps provider-supplied action labels when they already exist", () => {
expect(
resolveJVMActionDisplay({
action: "invoke",
label: "执行重置",
description: "调用 reset 操作",
}),
).toEqual({
action: "invoke",
label: "执行重置",
description: "调用 reset 操作",
});
});
it("formats the supported action summary with both localized label and code", () => {
expect(
formatJVMActionSummary([
{ action: "set" },
{ action: "invoke", label: "执行重置" },
]),
).toBe("设置属性set, 执行重置invoke");
});
it("localizes risk levels and audit result states", () => {
expect(formatJVMRiskLevelText("medium")).toBe("中");
expect(formatJVMRiskLevelText("")).toBe("未知");
expect(formatJVMAuditResultLabel("applied")).toBe("已执行");
expect(formatJVMAuditResultLabel("error")).toBe("失败");
expect(resolveJVMAuditResultColor("warning")).toBe("gold");
});
it("uses json mode for structured snapshots", () => {
expect(resolveJVMValueEditorLanguage("json", { name: "orders" })).toBe(
"json",
);
expect(resolveJVMValueEditorLanguage("array", [{ id: 1 }])).toBe("json");
});
it("detects JSON-looking strings so the preview can use the structured editor", () => {
expect(
resolveJVMValueEditorLanguage("string", '{\"name\":\"orders\"}'),
).toBe("json");
});
it("falls back to plaintext for ordinary string values", () => {
expect(resolveJVMValueEditorLanguage("string", "cache-enabled")).toBe(
"plaintext",
);
});
it("caps editor height for very long payloads while keeping short content compact", () => {
expect(estimateJVMResourceEditorHeight("line-1")).toBe(180);
expect(
estimateJVMResourceEditorHeight(new Array(80).fill("line").join("\n")),
).toBe(420);
});
});

View File

@@ -0,0 +1,238 @@
import type { JVMActionDefinition } from "../types";
type JVMActionDisplay = {
action: string;
label: string;
description?: string;
};
const ACTION_FALLBACK_META: Record<
string,
{ label: string; description?: string }
> = {
set: {
label: "设置属性",
description: "更新当前资源暴露的可写属性值。",
},
invoke: {
label: "调用操作",
description: "调用当前资源暴露的管理操作。",
},
put: {
label: "写入资源",
description: "将 payload 内容写入当前 JVM 资源。",
},
clear: {
label: "清空资源",
description: "清空当前 JVM 资源里的数据或状态。",
},
evict: {
label: "驱逐缓存",
description: "将目标缓存项从当前 JVM 运行时中驱逐。",
},
remove: {
label: "删除条目",
description: "删除当前资源中的指定条目。",
},
delete: {
label: "删除资源",
description: "删除或注销当前资源。",
},
refresh: {
label: "刷新资源",
description: "刷新当前资源的运行时状态。",
},
reload: {
label: "重新加载",
description: "重新加载当前资源或其配置。",
},
reset: {
label: "重置状态",
description: "将当前资源恢复到初始或默认状态。",
},
};
const normalizeText = (value: unknown): string => String(value || "").trim();
const looksLikeStructuredJSONText = (value: string): boolean => {
const trimmed = normalizeText(value);
if (!trimmed) {
return false;
}
if (
!(
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"))
)
) {
return false;
}
try {
JSON.parse(trimmed);
return true;
} catch {
return false;
}
};
export const resolveJVMActionDisplay = (
value?: Partial<JVMActionDefinition> | string | null,
): JVMActionDisplay => {
const action = normalizeText(
typeof value === "string" ? value : value?.action,
);
const fallback = ACTION_FALLBACK_META[action.toLowerCase()] || null;
const label =
normalizeText(typeof value === "string" ? "" : value?.label) ||
fallback?.label ||
action ||
"未命名动作";
const description =
normalizeText(typeof value === "string" ? "" : value?.description) ||
fallback?.description ||
"";
return {
action,
label,
description: description || undefined,
};
};
export const formatJVMActionDisplayText = (
value?: Partial<JVMActionDefinition> | string | null,
): string => {
const resolved = resolveJVMActionDisplay(value);
if (!resolved.action || resolved.label === resolved.action) {
return resolved.label;
}
return `${resolved.label}${resolved.action}`;
};
export const formatJVMActionSummary = (
actions?: JVMActionDefinition[] | null,
): string => {
if (!Array.isArray(actions) || actions.length === 0) {
return "-";
}
return actions
.map((item) => formatJVMActionDisplayText(item))
.filter((item) => item !== "")
.join(", ");
};
export const formatJVMRiskLevelText = (value?: string | null): string => {
const normalized = normalizeText(value).toLowerCase();
if (normalized === "low") {
return "低";
}
if (normalized === "medium") {
return "中";
}
if (normalized === "high") {
return "高";
}
return normalizeText(value) || "未知";
};
export const resolveJVMAuditResultColor = (value?: string | null): string => {
const normalized = normalizeText(value).toLowerCase();
if (
normalized === "applied" ||
normalized.includes("success") ||
normalized.includes("ok") ||
normalized.includes("done")
) {
return "green";
}
if (normalized.includes("warn")) {
return "gold";
}
if (
normalized.includes("block") ||
normalized.includes("deny") ||
normalized.includes("forbid") ||
normalized.includes("fail") ||
normalized.includes("error")
) {
return "red";
}
return "default";
};
export const formatJVMAuditResultLabel = (value?: string | null): string => {
const normalized = normalizeText(value).toLowerCase();
if (!normalized) {
return "未知";
}
if (normalized === "applied") {
return "已执行";
}
if (
normalized.includes("success") ||
normalized.includes("ok") ||
normalized.includes("done")
) {
return "成功";
}
if (normalized.includes("warn")) {
return "警告";
}
if (
normalized.includes("block") ||
normalized.includes("deny") ||
normalized.includes("forbid")
) {
return "已阻断";
}
if (normalized.includes("fail") || normalized.includes("error")) {
return "失败";
}
return normalizeText(value);
};
export const resolveJVMValueEditorLanguage = (
format: string,
value: unknown,
): string => {
const normalizedFormat = normalizeText(format).toLowerCase();
if (
["json", "array", "object", "number", "boolean", "null"].includes(
normalizedFormat,
)
) {
return "json";
}
if (normalizedFormat === "sql") {
return "sql";
}
if (normalizedFormat === "xml") {
return "xml";
}
if (normalizedFormat === "yaml" || normalizedFormat === "yml") {
return "yaml";
}
if (typeof value === "string") {
return looksLikeStructuredJSONText(value) ? "json" : "plaintext";
}
if (
value === null ||
typeof value === "number" ||
typeof value === "boolean" ||
Array.isArray(value)
) {
return "json";
}
if (value && typeof value === "object") {
return "json";
}
return "plaintext";
};
export const estimateJVMResourceEditorHeight = (value: unknown): number => {
const text = String(value ?? "");
const lineCount = Math.max(1, text.split(/\r?\n/).length);
return Math.min(420, Math.max(180, lineCount * 22 + 24));
};
export type { JVMActionDisplay };

View File

@@ -1,5 +1,5 @@
export type JVMRuntimeMode = 'jmx' | 'endpoint' | 'agent';
export type JVMTabKind = 'overview' | 'resource' | 'audit';
export type JVMTabKind = 'overview' | 'resource' | 'audit' | 'diagnostic';
export type JVMModeMeta = {
mode: string;
@@ -35,6 +35,7 @@ const JVM_TAB_KIND_LABELS: Record<JVMTabKind, string> = {
overview: 'JVM 概览',
resource: 'JVM 资源',
audit: 'JVM 审计',
diagnostic: 'JVM 诊断',
};
const normalizeMode = (mode: string): string => String(mode || '').trim().toLowerCase();

View File

@@ -130,16 +130,26 @@ export function InstallUpdateAndRestart():Promise<connection.QueryResult>;
export function JVMApplyChange(arg1:connection.ConnectionConfig,arg2:jvm.ChangeRequest):Promise<connection.QueryResult>;
export function JVMCancelDiagnosticCommand(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function JVMExecuteDiagnosticCommand(arg1:connection.ConnectionConfig,arg2:string,arg3:jvm.DiagnosticCommandRequest):Promise<connection.QueryResult>;
export function JVMGetValue(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function JVMListAuditRecords(arg1:string,arg2:number):Promise<connection.QueryResult>;
export function JVMListDiagnosticAuditRecords(arg1:string,arg2:number):Promise<connection.QueryResult>;
export function JVMListResources(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function JVMPreviewChange(arg1:connection.ConnectionConfig,arg2:jvm.ChangeRequest):Promise<connection.QueryResult>;
export function JVMProbeCapabilities(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function JVMProbeDiagnosticCapabilities(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function JVMStartDiagnosticSession(arg1:connection.ConnectionConfig,arg2:jvm.DiagnosticSessionRequest):Promise<connection.QueryResult>;
export function ListSQLDirectory(arg1:string):Promise<connection.QueryResult>;
export function LogWindowDiagnostic(arg1:string,arg2:string):Promise<void>;

View File

@@ -250,6 +250,14 @@ export function JVMApplyChange(arg1, arg2) {
return window['go']['app']['App']['JVMApplyChange'](arg1, arg2);
}
export function JVMCancelDiagnosticCommand(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['JVMCancelDiagnosticCommand'](arg1, arg2, arg3, arg4);
}
export function JVMExecuteDiagnosticCommand(arg1, arg2, arg3) {
return window['go']['app']['App']['JVMExecuteDiagnosticCommand'](arg1, arg2, arg3);
}
export function JVMGetValue(arg1, arg2) {
return window['go']['app']['App']['JVMGetValue'](arg1, arg2);
}
@@ -258,6 +266,10 @@ export function JVMListAuditRecords(arg1, arg2) {
return window['go']['app']['App']['JVMListAuditRecords'](arg1, arg2);
}
export function JVMListDiagnosticAuditRecords(arg1, arg2) {
return window['go']['app']['App']['JVMListDiagnosticAuditRecords'](arg1, arg2);
}
export function JVMListResources(arg1, arg2) {
return window['go']['app']['App']['JVMListResources'](arg1, arg2);
}
@@ -270,6 +282,14 @@ export function JVMProbeCapabilities(arg1) {
return window['go']['app']['App']['JVMProbeCapabilities'](arg1);
}
export function JVMProbeDiagnosticCapabilities(arg1) {
return window['go']['app']['App']['JVMProbeDiagnosticCapabilities'](arg1);
}
export function JVMStartDiagnosticSession(arg1, arg2) {
return window['go']['app']['App']['JVMStartDiagnosticSession'](arg1, arg2);
}
export function ListSQLDirectory(arg1) {
return window['go']['app']['App']['ListSQLDirectory'](arg1);
}

View File

@@ -456,6 +456,52 @@ export namespace connection {
return a;
}
}
export class JVMDiagnosticConfig {
enabled?: boolean;
transport?: string;
baseUrl?: string;
targetId?: string;
apiKey?: string;
allowObserveCommands?: boolean;
allowTraceCommands?: boolean;
allowMutatingCommands?: boolean;
timeoutSeconds?: number;
static createFrom(source: any = {}) {
return new JVMDiagnosticConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.transport = source["transport"];
this.baseUrl = source["baseUrl"];
this.targetId = source["targetId"];
this.apiKey = source["apiKey"];
this.allowObserveCommands = source["allowObserveCommands"];
this.allowTraceCommands = source["allowTraceCommands"];
this.allowMutatingCommands = source["allowMutatingCommands"];
this.timeoutSeconds = source["timeoutSeconds"];
}
}
export class JVMAgentConfig {
enabled?: boolean;
baseUrl?: string;
apiKey?: string;
timeoutSeconds?: number;
static createFrom(source: any = {}) {
return new JVMAgentConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.baseUrl = source["baseUrl"];
this.apiKey = source["apiKey"];
this.timeoutSeconds = source["timeoutSeconds"];
}
}
export class JVMEndpointConfig {
enabled?: boolean;
baseUrl?: string;
@@ -503,6 +549,8 @@ export namespace connection {
preferredMode?: string;
jmx?: JVMJMXConfig;
endpoint?: JVMEndpointConfig;
agent?: JVMAgentConfig;
diagnostic?: JVMDiagnosticConfig;
static createFrom(source: any = {}) {
return new JVMConfig(source);
@@ -516,6 +564,8 @@ export namespace connection {
this.preferredMode = source["preferredMode"];
this.jmx = this.convertValues(source["jmx"], JVMJMXConfig);
this.endpoint = this.convertValues(source["endpoint"], JVMEndpointConfig);
this.agent = this.convertValues(source["agent"], JVMAgentConfig);
this.diagnostic = this.convertValues(source["diagnostic"], JVMDiagnosticConfig);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -723,6 +773,8 @@ export namespace connection {
export class QueryResult {
success: boolean;
message: string;
@@ -888,31 +940,68 @@ export namespace connection {
}
export namespace jvm {
export class ChangeRequest {
providerMode: string;
resourceId: string;
action: string;
reason: string;
source?: string;
expectedVersion?: string;
payload?: Record<string, any>;
static createFrom(source: any = {}) {
return new ChangeRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.providerMode = source["providerMode"];
this.resourceId = source["resourceId"];
this.action = source["action"];
this.reason = source["reason"];
this.source = source["source"];
this.expectedVersion = source["expectedVersion"];
this.payload = source["payload"];
}
}
export class DiagnosticCommandRequest {
sessionId: string;
commandId: string;
command: string;
source?: string;
reason?: string;
static createFrom(source: any = {}) {
return new DiagnosticCommandRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.sessionId = source["sessionId"];
this.commandId = source["commandId"];
this.command = source["command"];
this.source = source["source"];
this.reason = source["reason"];
}
}
export class DiagnosticSessionRequest {
title?: string;
reason?: string;
static createFrom(source: any = {}) {
return new DiagnosticSessionRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.title = source["title"];
this.reason = source["reason"];
}
}
}
export namespace redis {
export class ZSetMember {
@@ -1034,3 +1123,4 @@ export namespace sync {
}
}

2
go.mod
View File

@@ -62,7 +62,7 @@ require (
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/gorilla/websocket v1.5.3
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect

View File

@@ -10,6 +10,16 @@ import (
var newJVMProvider = jvm.NewProvider
func buildJVMCapabilityError(mode string, cfg connection.ConnectionConfig, err error) jvm.Capability {
probeCfg := cfg
probeCfg.JVM.PreferredMode = mode
return jvm.Capability{
Mode: mode,
DisplayLabel: jvm.ModeDisplayLabel(mode),
Reason: jvm.DescribeConnectionTestError(probeCfg, err),
}
}
func resolveJVMProvider(cfg connection.ConnectionConfig) (connection.ConnectionConfig, jvm.Provider, error) {
return resolveJVMProviderForMode(cfg, "")
}
@@ -37,7 +47,7 @@ func (a *App) TestJVMConnection(cfg connection.ConnectionConfig) connection.Quer
}
if err := provider.TestConnection(a.ctx, normalized); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
return connection.QueryResult{Success: false, Message: jvm.DescribeConnectionTestError(normalized, err)}
}
return connection.QueryResult{Success: true, Message: "JVM 连接成功"}
@@ -114,6 +124,7 @@ func (a *App) JVMApplyChange(cfg connection.ConnectionConfig, req jvm.ChangeRequ
ResourceID: req.ResourceID,
Action: req.Action,
Reason: req.Reason,
Source: req.Source,
Result: result.Status,
}); err != nil {
if strings.TrimSpace(result.Message) == "" {
@@ -142,23 +153,18 @@ func (a *App) JVMProbeCapabilities(cfg connection.ConnectionConfig) connection.Q
items := make([]jvm.Capability, 0, len(normalized.JVM.AllowedModes))
for _, mode := range normalized.JVM.AllowedModes {
probeCfg := normalized
probeCfg.JVM.PreferredMode = mode
provider, providerErr := newJVMProvider(mode)
if providerErr != nil {
items = append(items, jvm.Capability{
Mode: mode,
DisplayLabel: jvm.ModeDisplayLabel(mode),
Reason: providerErr.Error(),
})
items = append(items, buildJVMCapabilityError(mode, probeCfg, providerErr))
continue
}
caps, probeErr := provider.ProbeCapabilities(a.ctx, normalized)
caps, probeErr := provider.ProbeCapabilities(a.ctx, probeCfg)
if probeErr != nil {
items = append(items, jvm.Capability{
Mode: mode,
DisplayLabel: jvm.ModeDisplayLabel(mode),
Reason: probeErr.Error(),
})
items = append(items, buildJVMCapabilityError(mode, probeCfg, probeErr))
continue
}

View File

@@ -0,0 +1,294 @@
package app
import (
"errors"
"fmt"
"path/filepath"
"strings"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/jvm"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
var newJVMDiagnosticTransport = jvm.NewDiagnosticTransport
const diagnosticChunkEvent = "jvm:diagnostic:chunk"
type diagnosticChunkEventPayload struct {
TabID string `json:"tabId"`
Chunk jvm.DiagnosticEventChunk `json:"chunk"`
}
func swapJVMDiagnosticTransportFactory(factory func(mode string) (jvm.DiagnosticTransport, error)) func() {
prev := newJVMDiagnosticTransport
newJVMDiagnosticTransport = factory
return func() { newJVMDiagnosticTransport = prev }
}
func resolveJVMDiagnosticTransport(cfg connection.ConnectionConfig) (connection.ConnectionConfig, jvm.DiagnosticTransport, error) {
normalized, err := jvm.NormalizeConnectionConfig(cfg)
if err != nil {
return connection.ConnectionConfig{}, nil, err
}
diagCfg, err := jvm.NormalizeDiagnosticConfig(normalized)
if err != nil {
return connection.ConnectionConfig{}, nil, err
}
if !diagCfg.Enabled {
return connection.ConnectionConfig{}, nil, errors.New("当前连接未启用 JVM 诊断增强模式")
}
normalized.JVM.Diagnostic = diagCfg
transport, err := newJVMDiagnosticTransport(diagCfg.Transport)
if err != nil {
return connection.ConnectionConfig{}, nil, err
}
return normalized, transport, nil
}
func (a *App) JVMProbeDiagnosticCapabilities(cfg connection.ConnectionConfig) connection.QueryResult {
normalized, transport, err := resolveJVMDiagnosticTransport(cfg)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
items, err := transport.ProbeCapabilities(a.ctx, normalized)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: items}
}
func (a *App) JVMStartDiagnosticSession(cfg connection.ConnectionConfig, req jvm.DiagnosticSessionRequest) connection.QueryResult {
normalized, transport, err := resolveJVMDiagnosticTransport(cfg)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
handle, err := transport.StartSession(a.ctx, normalized, req)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: handle}
}
func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID string, req jvm.DiagnosticCommandRequest) connection.QueryResult {
normalized, transport, err := resolveJVMDiagnosticTransport(cfg)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
req.SessionID = strings.TrimSpace(req.SessionID)
req.CommandID = strings.TrimSpace(req.CommandID)
req.Command = strings.TrimSpace(req.Command)
req.Source = strings.TrimSpace(req.Source)
req.Reason = strings.TrimSpace(req.Reason)
if req.SessionID == "" {
return connection.QueryResult{Success: false, Message: "诊断会话 ID 不能为空,请先创建会话"}
}
if req.Command == "" {
return connection.QueryResult{Success: false, Message: "诊断命令不能为空"}
}
if req.CommandID == "" {
req.CommandID = fmt.Sprintf("diag-%d", time.Now().UnixNano())
}
if req.Source == "" {
req.Source = "manual"
}
commandType, err := jvm.ValidateDiagnosticCommandPolicy(normalized.JVM.Diagnostic, req.Command)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
riskLevel := diagnosticRiskLevel(commandType)
auditStore := jvm.NewDiagnosticAuditStore(filepath.Join(a.auditRootDir(), "jvm_diag_audit.jsonl"))
var auditWarnings []string
if err := auditStore.Append(jvm.DiagnosticAuditRecord{
ConnectionID: normalized.ID,
SessionID: req.SessionID,
CommandID: req.CommandID,
Transport: normalized.JVM.Diagnostic.Transport,
Command: req.Command,
CommandType: commandType,
Source: req.Source,
Reason: req.Reason,
RiskLevel: riskLevel,
Status: "running",
}); err != nil {
auditWarnings = append(auditWarnings, "审计记录写入失败: "+err.Error())
}
terminalSeen := false
appendTerminalAudit := func(status string) {
if terminalSeen {
return
}
terminalSeen = true
if err := auditStore.Append(jvm.DiagnosticAuditRecord{
ConnectionID: normalized.ID,
SessionID: req.SessionID,
CommandID: req.CommandID,
Transport: normalized.JVM.Diagnostic.Transport,
Command: req.Command,
CommandType: commandType,
Source: req.Source,
Reason: req.Reason,
RiskLevel: riskLevel,
Status: status,
}); err != nil {
auditWarnings = append(auditWarnings, "审计记录写入失败: "+err.Error())
}
}
if binder, ok := transport.(interface{ SetEventSink(jvm.DiagnosticEventSink) }); ok {
binder.SetEventSink(func(chunk jvm.DiagnosticEventChunk) {
if chunk.Timestamp == 0 {
chunk.Timestamp = time.Now().UnixMilli()
}
if strings.TrimSpace(chunk.SessionID) == "" {
chunk.SessionID = req.SessionID
}
if strings.TrimSpace(chunk.CommandID) == "" {
chunk.CommandID = req.CommandID
}
a.emitDiagnosticChunk(tabID, chunk)
if isDiagnosticTerminalPhase(chunk.Phase) {
appendTerminalAudit(chunk.Phase)
}
})
}
if err := transport.ExecuteCommand(a.ctx, normalized, req); err != nil {
phase := "failed"
if strings.Contains(strings.ToLower(err.Error()), "canceled") {
phase = "canceled"
}
if !terminalSeen {
chunk := jvm.DiagnosticEventChunk{
SessionID: req.SessionID,
CommandID: req.CommandID,
Event: "diagnostic",
Phase: phase,
Content: err.Error(),
Timestamp: time.Now().UnixMilli(),
}
a.emitDiagnosticChunk(tabID, chunk)
appendTerminalAudit(phase)
}
return connection.QueryResult{Success: false, Message: joinDiagnosticMessages(err.Error(), auditWarnings)}
}
if !terminalSeen {
chunk := jvm.DiagnosticEventChunk{
SessionID: req.SessionID,
CommandID: req.CommandID,
Event: "diagnostic",
Phase: "completed",
Content: "诊断命令执行完成",
Timestamp: time.Now().UnixMilli(),
}
a.emitDiagnosticChunk(tabID, chunk)
appendTerminalAudit("completed")
}
return connection.QueryResult{
Success: true,
Message: joinDiagnosticMessages("", auditWarnings),
Data: map[string]any{
"sessionId": req.SessionID,
"commandId": req.CommandID,
"status": "accepted",
},
}
}
func (a *App) JVMCancelDiagnosticCommand(cfg connection.ConnectionConfig, tabID string, sessionID string, commandID string) connection.QueryResult {
normalized, transport, err := resolveJVMDiagnosticTransport(cfg)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
sessionID = strings.TrimSpace(sessionID)
commandID = strings.TrimSpace(commandID)
if sessionID == "" || commandID == "" {
return connection.QueryResult{Success: false, Message: "取消命令缺少 sessionId 或 commandId"}
}
if err := transport.CancelCommand(a.ctx, normalized, sessionID, commandID); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
a.emitDiagnosticChunk(tabID, jvm.DiagnosticEventChunk{
SessionID: sessionID,
CommandID: commandID,
Event: "diagnostic",
Phase: "canceling",
Content: "已发送取消请求,等待诊断桥接端结束命令",
Timestamp: time.Now().UnixMilli(),
})
return connection.QueryResult{
Success: true,
Data: map[string]any{
"sessionId": sessionID,
"commandId": commandID,
"status": "cancel-requested",
},
}
}
func (a *App) JVMListDiagnosticAuditRecords(connectionID string, limit int) connection.QueryResult {
records, err := jvm.NewDiagnosticAuditStore(filepath.Join(a.auditRootDir(), "jvm_diag_audit.jsonl")).List(connectionID, limit)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: records}
}
func (a *App) emitDiagnosticChunk(tabID string, chunk jvm.DiagnosticEventChunk) {
if a.ctx == nil {
return
}
runtime.EventsEmit(a.ctx, diagnosticChunkEvent, diagnosticChunkEventPayload{
TabID: strings.TrimSpace(tabID),
Chunk: chunk,
})
}
func diagnosticRiskLevel(commandType string) string {
switch strings.TrimSpace(commandType) {
case jvm.DiagnosticCommandCategoryObserve:
return "low"
case jvm.DiagnosticCommandCategoryTrace:
return "medium"
default:
return "high"
}
}
func isDiagnosticTerminalPhase(phase string) bool {
switch strings.ToLower(strings.TrimSpace(phase)) {
case "completed", "failed", "canceled":
return true
default:
return false
}
}
func joinDiagnosticMessages(primary string, warnings []string) string {
items := make([]string, 0, 1+len(warnings))
if strings.TrimSpace(primary) != "" {
items = append(items, strings.TrimSpace(primary))
}
for _, warning := range warnings {
if strings.TrimSpace(warning) == "" {
continue
}
items = append(items, strings.TrimSpace(warning))
}
return strings.Join(items, "")
}

View File

@@ -0,0 +1,255 @@
package app
import (
"context"
"path/filepath"
"testing"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/jvm"
)
type fakeDiagnosticTransport struct {
testErr error
caps []jvm.DiagnosticCapability
capsErr error
handle jvm.DiagnosticSessionHandle
startErr error
executeReq jvm.DiagnosticCommandRequest
executeErr error
cancelSession string
cancelCommand string
cancelErr error
}
func (f fakeDiagnosticTransport) Mode() string { return jvm.DiagnosticTransportAgentBridge }
func (f fakeDiagnosticTransport) TestConnection(context.Context, connection.ConnectionConfig) error {
return f.testErr
}
func (f fakeDiagnosticTransport) ProbeCapabilities(context.Context, connection.ConnectionConfig) ([]jvm.DiagnosticCapability, error) {
return f.caps, f.capsErr
}
func (f fakeDiagnosticTransport) StartSession(context.Context, connection.ConnectionConfig, jvm.DiagnosticSessionRequest) (jvm.DiagnosticSessionHandle, error) {
return f.handle, f.startErr
}
func (f fakeDiagnosticTransport) ExecuteCommand(context.Context, connection.ConnectionConfig, jvm.DiagnosticCommandRequest) error {
return f.executeErr
}
func (f fakeDiagnosticTransport) CancelCommand(context.Context, connection.ConnectionConfig, string, string) error {
return f.cancelErr
}
func (f fakeDiagnosticTransport) CloseSession(context.Context, connection.ConnectionConfig, string) error {
return nil
}
func TestJVMProbeDiagnosticCapabilitiesReturnsTransportPayload(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return fakeDiagnosticTransport{
caps: []jvm.DiagnosticCapability{{
Transport: jvm.DiagnosticTransportAgentBridge,
CanOpenSession: true,
CanStream: true,
}},
}, nil
})
defer restore()
res := app.JVMProbeDiagnosticCapabilities(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
},
},
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
items, ok := res.Data.([]jvm.DiagnosticCapability)
if !ok {
t.Fatalf("expected diagnostic capability payload, got %#v", res.Data)
}
if len(items) != 1 || items[0].Transport != jvm.DiagnosticTransportAgentBridge {
t.Fatalf("unexpected diagnostic capabilities: %#v", items)
}
}
func TestJVMStartDiagnosticSessionReturnsHandle(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return fakeDiagnosticTransport{
handle: jvm.DiagnosticSessionHandle{
SessionID: "sess-1",
Transport: jvm.DiagnosticTransportAgentBridge,
StartedAt: 1713945600000,
},
}, nil
})
defer restore()
res := app.JVMStartDiagnosticSession(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
},
},
}, jvm.DiagnosticSessionRequest{
Title: "排查线程堆积",
Reason: "先建立诊断会话",
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
handle, ok := res.Data.(jvm.DiagnosticSessionHandle)
if !ok {
t.Fatalf("expected diagnostic session handle, got %#v", res.Data)
}
if handle.SessionID != "sess-1" || handle.Transport != jvm.DiagnosticTransportAgentBridge {
t.Fatalf("unexpected diagnostic session handle: %#v", handle)
}
}
func TestJVMExecuteDiagnosticCommandReturnsAccepted(t *testing.T) {
app := NewAppWithSecretStore(nil)
recorder := &fakeDiagnosticTransport{}
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return diagnosticTransportRecorder{recorder: recorder}, nil
})
defer restore()
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
ID: "conn-orders",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
AllowObserveCommands: true,
},
},
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
SessionID: "sess-1",
CommandID: "cmd-1",
Command: "thread -n 5",
Source: "manual",
Reason: "定位线程堆积",
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
if recorder.executeReq.Command != "thread -n 5" || recorder.executeReq.SessionID != "sess-1" {
t.Fatalf("unexpected execute request: %#v", recorder.executeReq)
}
}
func TestJVMCancelDiagnosticCommandDelegatesToTransport(t *testing.T) {
app := NewAppWithSecretStore(nil)
recorder := &fakeDiagnosticTransport{}
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return diagnosticTransportRecorder{recorder: recorder}, nil
})
defer restore()
res := app.JVMCancelDiagnosticCommand(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
},
},
}, "tab-diag-1", "sess-1", "cmd-1")
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
if recorder.cancelSession != "sess-1" || recorder.cancelCommand != "cmd-1" {
t.Fatalf("unexpected cancel request: %#v", recorder)
}
}
func TestJVMListDiagnosticAuditRecordsReturnsRecords(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.configDir = t.TempDir()
store := jvm.NewDiagnosticAuditStore(filepath.Join(app.auditRootDir(), "jvm_diag_audit.jsonl"))
if err := store.Append(jvm.DiagnosticAuditRecord{
ConnectionID: "conn-orders",
Transport: jvm.DiagnosticTransportAgentBridge,
SessionID: "sess-1",
CommandID: "cmd-1",
Command: "thread -n 5",
CommandType: jvm.DiagnosticCommandCategoryObserve,
RiskLevel: "low",
Status: "completed",
Reason: "定位线程堆积",
}); err != nil {
t.Fatalf("append audit record failed: %v", err)
}
res := app.JVMListDiagnosticAuditRecords("conn-orders", 10)
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
records, ok := res.Data.([]jvm.DiagnosticAuditRecord)
if !ok {
t.Fatalf("expected audit record slice, got %#v", res.Data)
}
if len(records) != 1 || records[0].Command != "thread -n 5" {
t.Fatalf("unexpected audit records: %#v", records)
}
}
type diagnosticTransportRecorder struct {
recorder *fakeDiagnosticTransport
}
func (d diagnosticTransportRecorder) Mode() string { return jvm.DiagnosticTransportAgentBridge }
func (d diagnosticTransportRecorder) TestConnection(ctx context.Context, cfg connection.ConnectionConfig) error {
return d.recorder.TestConnection(ctx, cfg)
}
func (d diagnosticTransportRecorder) ProbeCapabilities(ctx context.Context, cfg connection.ConnectionConfig) ([]jvm.DiagnosticCapability, error) {
return d.recorder.ProbeCapabilities(ctx, cfg)
}
func (d diagnosticTransportRecorder) StartSession(ctx context.Context, cfg connection.ConnectionConfig, req jvm.DiagnosticSessionRequest) (jvm.DiagnosticSessionHandle, error) {
return d.recorder.StartSession(ctx, cfg, req)
}
func (d diagnosticTransportRecorder) ExecuteCommand(ctx context.Context, cfg connection.ConnectionConfig, req jvm.DiagnosticCommandRequest) error {
d.recorder.executeReq = req
return d.recorder.ExecuteCommand(ctx, cfg, req)
}
func (d diagnosticTransportRecorder) CancelCommand(ctx context.Context, cfg connection.ConnectionConfig, sessionID string, commandID string) error {
d.recorder.cancelSession = sessionID
d.recorder.cancelCommand = commandID
return d.recorder.CancelCommand(ctx, cfg, sessionID, commandID)
}
func (d diagnosticTransportRecorder) CloseSession(ctx context.Context, cfg connection.ConnectionConfig, sessionID string) error {
return d.recorder.CloseSession(ctx, cfg, sessionID)
}

View File

@@ -109,6 +109,64 @@ func TestTestJVMConnectionReturnsProviderError(t *testing.T) {
}
}
func TestTestJVMConnectionTranslatesJMXBusinessPortError(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{testErr: errors.New("jmx test connection failed: jmx helper ping failed for localhost:18080: JMX command ping failed for localhost:18080: Failed to retrieve RMIServer stub: javax.naming.CommunicationException [Root exception is java.rmi.ConnectIOException: non-JRMP server at remote endpoint]; details={\"exception\":\"java.lang.IllegalStateException\"}")}, nil
})
defer restore()
res := app.TestJVMConnection(connection.ConnectionConfig{
Type: "jvm",
Host: "localhost",
Port: 18080,
JVM: connection.JVMConfig{
PreferredMode: "jmx",
AllowedModes: []string{"jmx"},
},
})
if res.Success {
t.Fatalf("expected failure, got %+v", res)
}
if !strings.Contains(res.Message, "不是标准 JMX 远程管理端口") {
t.Fatalf("expected translated summary, got %q", res.Message)
}
if !strings.Contains(res.Message, "业务 `server.port`") {
t.Fatalf("expected actionable suggestion, got %q", res.Message)
}
if !strings.Contains(res.Message, "技术细节:") {
t.Fatalf("expected raw technical detail to be preserved, got %q", res.Message)
}
}
func TestTestJVMConnectionTranslatesAgentConnectionRefused(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{testErr: errors.New("agent probe request failed: Get \"http://127.0.0.1:19090/gonavi/agent/jvm\": dial tcp 127.0.0.1:19090: connect: connection refused")}, nil
})
defer restore()
res := app.TestJVMConnection(connection.ConnectionConfig{
Type: "jvm",
Host: "127.0.0.1",
JVM: connection.JVMConfig{
PreferredMode: "agent",
AllowedModes: []string{"agent"},
},
})
if res.Success {
t.Fatalf("expected failure, got %+v", res)
}
if !strings.Contains(res.Message, "目标 Agent 管理端口未监听") {
t.Fatalf("expected translated summary, got %q", res.Message)
}
if !strings.Contains(res.Message, "`-javaagent`") {
t.Fatalf("expected actionable suggestion, got %q", res.Message)
}
}
func TestTestJVMConnectionReturnsProviderFactoryError(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
@@ -190,6 +248,37 @@ func TestJVMProbeCapabilitiesIncludesReasonWhenProbeFails(t *testing.T) {
}
}
func TestJVMProbeCapabilitiesTranslatesJMXProbeErrorUsingCurrentMode(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{
probeErr: errors.New("jmx test connection failed: jmx helper ping failed for localhost:18080: JMX command ping failed for localhost:18080: Failed to retrieve RMIServer stub: javax.naming.CommunicationException [Root exception is java.rmi.ConnectIOException: non-JRMP server at remote endpoint]; details={\"exception\":\"java.lang.IllegalStateException\"}"),
}, nil
})
defer restore()
res := app.JVMProbeCapabilities(connection.ConnectionConfig{
Type: "jvm",
Host: "localhost",
Port: 18080,
JVM: connection.JVMConfig{
PreferredMode: "endpoint",
AllowedModes: []string{"jmx"},
},
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
items, ok := res.Data.([]jvm.Capability)
if !ok || len(items) != 1 {
t.Fatalf("expected one capability, got %#v", res.Data)
}
if !strings.Contains(items[0].Reason, "不是标准 JMX 远程管理端口") {
t.Fatalf("expected translated JMX reason, got %#v", items[0])
}
}
func TestJVMProbeCapabilitiesIncludesReasonWhenProviderFactoryFails(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
@@ -221,7 +310,7 @@ func TestJVMProbeCapabilitiesIncludesReasonWhenProviderFactoryFails(t *testing.T
}
}
func TestJVMProbeCapabilitiesUsesReadableLabelForUnsupportedMode(t *testing.T) {
func TestJVMProbeCapabilitiesUsesReadableLabelForAgentValidationError(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMProviderFactory(jvm.NewProvider)
defer restore()
@@ -245,8 +334,37 @@ func TestJVMProbeCapabilitiesUsesReadableLabelForUnsupportedMode(t *testing.T) {
if items[0].DisplayLabel != "Agent" {
t.Fatalf("expected display label %q, got %#v", "Agent", items[0])
}
if !strings.Contains(items[0].Reason, "unsupported jvm provider mode") {
t.Fatalf("expected unsupported mode error, got %#v", items[0])
if !strings.Contains(items[0].Reason, "未填写 Agent Base URL") {
t.Fatalf("expected agent validation error, got %#v", items[0])
}
}
func TestJVMProbeCapabilitiesUsesReadableLabelForEndpointValidationError(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMProviderFactory(jvm.NewProvider)
defer restore()
res := app.JVMProbeCapabilities(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: "endpoint",
AllowedModes: []string{"endpoint"},
},
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
items, ok := res.Data.([]jvm.Capability)
if !ok || len(items) != 1 {
t.Fatalf("expected one capability, got %#v", res.Data)
}
if items[0].DisplayLabel != "Endpoint" {
t.Fatalf("expected display label %q, got %#v", "Endpoint", items[0])
}
if !strings.Contains(items[0].Reason, "未填写 Endpoint Base URL") {
t.Fatalf("expected endpoint validation error, got %#v", items[0])
}
}
@@ -395,6 +513,71 @@ func TestJVMApplyChangeReturnsProviderPayload(t *testing.T) {
}
}
func TestJVMApplyChangePersistsAuditSource(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.configDir = t.TempDir()
readOnly := false
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{
value: jvm.ValueSnapshot{
ResourceID: "/cache/orders",
Kind: "entry",
Format: "json",
Value: map[string]any{
"status": "stale",
},
},
apply: jvm.ApplyResult{
Status: "applied",
UpdatedValue: jvm.ValueSnapshot{
ResourceID: "/cache/orders",
Kind: "entry",
Format: "json",
Value: map[string]any{
"status": "ready",
},
},
},
}, nil
})
defer restore()
res := app.JVMApplyChange(connection.ConnectionConfig{
Type: "jvm",
ID: "conn-orders",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
PreferredMode: "endpoint",
AllowedModes: []string{"endpoint"},
},
}, jvm.ChangeRequest{
ProviderMode: "endpoint",
ResourceID: "/cache/orders",
Action: "put",
Reason: "repair cache",
Source: "ai-plan",
Payload: map[string]any{
"status": "ready",
},
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
listRes := app.JVMListAuditRecords("conn-orders", 10)
if !listRes.Success {
t.Fatalf("expected audit list success, got %+v", listRes)
}
records, ok := listRes.Data.([]jvm.AuditRecord)
if !ok || len(records) != 1 {
t.Fatalf("expected one audit record, got %#v", listRes.Data)
}
if records[0].Source != "ai-plan" {
t.Fatalf("expected audit source %q, got %#v", "ai-plan", records[0])
}
}
func TestJVMPreviewChangeRejectsModeOutsideAllowedModes(t *testing.T) {
app := NewAppWithSecretStore(nil)

View File

@@ -44,14 +44,37 @@ type JVMEndpointConfig struct {
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
}
// JVMAgentConfig 存储 JVM Agent 管理端点配置。
type JVMAgentConfig struct {
Enabled bool `json:"enabled,omitempty"`
BaseURL string `json:"baseUrl,omitempty"`
APIKey string `json:"apiKey,omitempty"`
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
}
// JVMDiagnosticConfig 存储 JVM 诊断增强模式配置。
type JVMDiagnosticConfig struct {
Enabled bool `json:"enabled,omitempty"`
Transport string `json:"transport,omitempty"`
BaseURL string `json:"baseUrl,omitempty"`
TargetID string `json:"targetId,omitempty"`
APIKey string `json:"apiKey,omitempty"`
AllowObserveCommands bool `json:"allowObserveCommands,omitempty"`
AllowTraceCommands bool `json:"allowTraceCommands,omitempty"`
AllowMutatingCommands bool `json:"allowMutatingCommands,omitempty"`
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
}
// JVMConfig 存储 JVM 连接的协议与能力偏好配置。
type JVMConfig struct {
Environment string `json:"environment,omitempty"`
ReadOnly *bool `json:"readOnly,omitempty"`
AllowedModes []string `json:"allowedModes,omitempty"`
PreferredMode string `json:"preferredMode,omitempty"`
JMX JVMJMXConfig `json:"jmx,omitempty"`
Endpoint JVMEndpointConfig `json:"endpoint,omitempty"`
Environment string `json:"environment,omitempty"`
ReadOnly *bool `json:"readOnly,omitempty"`
AllowedModes []string `json:"allowedModes,omitempty"`
PreferredMode string `json:"preferredMode,omitempty"`
JMX JVMJMXConfig `json:"jmx,omitempty"`
Endpoint JVMEndpointConfig `json:"endpoint,omitempty"`
Agent JVMAgentConfig `json:"agent,omitempty"`
Diagnostic JVMDiagnosticConfig `json:"diagnostic,omitempty"`
}
// ConnectionConfig 存储数据库连接的完整配置,包括 SSH、代理、SSL 等网络层设置。

View File

@@ -0,0 +1,139 @@
package jvm
import (
"context"
"net/http"
"net/url"
"time"
"GoNavi-Wails/internal/connection"
)
type AgentProvider struct{}
func NewAgentProvider() Provider { return &AgentProvider{} }
func (p *AgentProvider) Mode() string { return ModeAgent }
func (p *AgentProvider) TestConnection(ctx context.Context, cfg connection.ConnectionConfig) error {
runtime, err := newAgentRuntime(cfg)
if err != nil {
return err
}
resp, err := doContractProbe(ctx, runtime.contractRuntime, http.MethodHead)
if err != nil {
return err
}
if resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented {
_ = resp.Body.Close()
resp, err = doContractProbe(ctx, runtime.contractRuntime, http.MethodGet)
if err != nil {
return err
}
}
defer resp.Body.Close()
if isReachableStatus(resp.StatusCode) {
return nil
}
return buildContractStatusError("agent", "probe", resp)
}
func (p *AgentProvider) ProbeCapabilities(_ context.Context, cfg connection.ConnectionConfig) ([]Capability, error) {
if _, err := newAgentRuntime(cfg); err != nil {
return nil, err
}
readOnly := cfg.JVM.ReadOnly != nil && *cfg.JVM.ReadOnly
return []Capability{{
Mode: ModeAgent,
CanBrowse: true,
CanWrite: !readOnly,
CanPreview: true,
DisplayLabel: "Agent",
Reason: func() string {
if readOnly {
return "当前连接只读"
}
return ""
}(),
}}, nil
}
func (p *AgentProvider) ListResources(ctx context.Context, cfg connection.ConnectionConfig, parentPath string) ([]ResourceSummary, error) {
runtime, err := newAgentRuntime(cfg)
if err != nil {
return nil, err
}
query := url.Values{}
query.Set("parentPath", parentPath)
var resources []ResourceSummary
if err := runtime.doJSON(ctx, http.MethodGet, "list resources", "resources", query, nil, &resources); err != nil {
return nil, err
}
return resources, nil
}
func (p *AgentProvider) GetValue(ctx context.Context, cfg connection.ConnectionConfig, resourcePath string) (ValueSnapshot, error) {
runtime, err := newAgentRuntime(cfg)
if err != nil {
return ValueSnapshot{}, err
}
query := url.Values{}
query.Set("resourcePath", resourcePath)
var snapshot ValueSnapshot
if err := runtime.doJSON(ctx, http.MethodGet, "get value", "value", query, nil, &snapshot); err != nil {
return ValueSnapshot{}, err
}
return snapshot, nil
}
func (p *AgentProvider) PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) {
runtime, err := newAgentRuntime(cfg)
if err != nil {
return ChangePreview{}, err
}
var preview ChangePreview
if err := runtime.doJSON(ctx, http.MethodPost, "preview change", "preview", nil, req, &preview); err != nil {
return ChangePreview{}, err
}
return preview, nil
}
func (p *AgentProvider) ApplyChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ApplyResult, error) {
runtime, err := newAgentRuntime(cfg)
if err != nil {
return ApplyResult{}, err
}
var result ApplyResult
if err := runtime.doJSON(ctx, http.MethodPost, "apply change", "apply", nil, req, &result); err != nil {
return ApplyResult{}, err
}
return result, nil
}
type agentRuntime struct {
contractRuntime
}
func newAgentRuntime(cfg connection.ConnectionConfig) (agentRuntime, error) {
timeout := time.Duration(cfg.JVM.Agent.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = time.Duration(cfg.Timeout) * time.Second
}
runtime, err := newContractRuntime(
cfg.JVM.Agent.BaseURL,
cfg.JVM.Agent.APIKey,
timeout,
"agent",
)
if err != nil {
return agentRuntime{}, err
}
return agentRuntime{contractRuntime: runtime}, nil
}

View File

@@ -0,0 +1,305 @@
package jvm
import (
"bufio"
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"GoNavi-Wails/internal/connection"
)
func TestAgentProviderListResourcesBuildsRequestAndDecodesResponse(t *testing.T) {
provider := NewAgentProvider()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatalf("expected GET request, got %s", r.Method)
}
if r.URL.Path != "/gonavi/agent/jvm/resources" {
t.Fatalf("expected path /gonavi/agent/jvm/resources, got %s", r.URL.Path)
}
if got := r.URL.Query().Get("parentPath"); got != "/runtime/cache" {
t.Fatalf("expected parentPath /runtime/cache, got %q", got)
}
if got := r.Header.Get("X-API-Key"); got != "secret-token" {
t.Fatalf("expected X-API-Key header to pass through, got %q", got)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]ResourceSummary{{
ID: "agent.cache",
Kind: "folder",
Name: "Agent Cache",
Path: "/runtime/cache",
ProviderMode: ModeAgent,
CanRead: true,
CanWrite: true,
HasChildren: true,
}})
}))
defer server.Close()
items, err := provider.ListResources(context.Background(), newAgentProviderTestConfig(server.URL+"/gonavi/agent/jvm", 3), "/runtime/cache")
if err != nil {
t.Fatalf("ListResources returned error: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 resource, got %#v", items)
}
if items[0].ProviderMode != ModeAgent || items[0].Path != "/runtime/cache" {
t.Fatalf("unexpected resource payload: %#v", items[0])
}
}
func TestAgentProviderRealAgentRoundTrip(t *testing.T) {
if _, err := exec.LookPath("java"); err != nil {
t.Skipf("java 不可用,跳过真实 Agent 集成测试: %v", err)
}
if _, err := exec.LookPath("javac"); err != nil {
t.Skipf("javac 不可用,跳过真实 Agent 集成测试: %v", err)
}
if _, err := exec.LookPath("jar"); err != nil {
t.Skipf("jar 不可用,跳过真实 Agent 集成测试: %v", err)
}
provider := NewAgentProvider()
fixture := startAgentFixture(t)
cfg := newAgentProviderTestConfig(fixture.baseURL+"/gonavi/agent/jvm", 5)
waitForTest(t, 10*time.Second, func() error {
return provider.TestConnection(context.Background(), cfg)
})
caps, err := provider.ProbeCapabilities(context.Background(), cfg)
if err != nil {
t.Fatalf("ProbeCapabilities returned error: %v", err)
}
if len(caps) != 1 || !caps[0].CanBrowse || !caps[0].CanWrite || !caps[0].CanPreview {
t.Fatalf("unexpected capabilities: %#v", caps)
}
root, err := provider.ListResources(context.Background(), cfg, "")
if err != nil {
t.Fatalf("ListResources(root) returned error: %v", err)
}
if len(root) != 1 || root[0].Name != "Agent Cache" {
t.Fatalf("unexpected root resources: %#v", root)
}
children, err := provider.ListResources(context.Background(), cfg, root[0].Path)
if err != nil {
t.Fatalf("ListResources(cache) returned error: %v", err)
}
if len(children) != 1 || children[0].Name != "user:1001" {
t.Fatalf("unexpected child resources: %#v", children)
}
entry := children[0]
before, err := provider.GetValue(context.Background(), cfg, entry.Path)
if err != nil {
t.Fatalf("GetValue(before) returned error: %v", err)
}
valueMap, ok := before.Value.(map[string]any)
if !ok {
t.Fatalf("expected JSON object snapshot, got %#v", before.Value)
}
if valueMap["status"] != "cold" {
t.Fatalf("expected initial status cold, got %#v", before.Value)
}
preview, err := provider.PreviewChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeAgent,
ResourceID: entry.Path,
Action: "put",
Reason: "预热用户缓存",
ExpectedVersion: before.Version,
Payload: map[string]any{
"status": "warm",
"score": 99,
},
})
if err != nil {
t.Fatalf("PreviewChange returned error: %v", err)
}
if !preview.Allowed || preview.After.ResourceID != entry.Path {
t.Fatalf("unexpected preview payload: %#v", preview)
}
result, err := provider.ApplyChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeAgent,
ResourceID: entry.Path,
Action: "put",
Reason: "预热用户缓存",
ExpectedVersion: before.Version,
Payload: map[string]any{
"status": "warm",
"score": 99,
},
})
if err != nil {
t.Fatalf("ApplyChange returned error: %v", err)
}
if result.Status != "applied" {
t.Fatalf("unexpected apply payload: %#v", result)
}
after, err := provider.GetValue(context.Background(), cfg, entry.Path)
if err != nil {
t.Fatalf("GetValue(after) returned error: %v", err)
}
afterMap, ok := after.Value.(map[string]any)
if !ok {
t.Fatalf("expected JSON object snapshot after apply, got %#v", after.Value)
}
if afterMap["status"] != "warm" {
t.Fatalf("expected status warm after apply, got %#v", after.Value)
}
}
type agentFixtureProcess struct {
port int
baseURL string
cmd *exec.Cmd
}
func startAgentFixture(t *testing.T) agentFixtureProcess {
t.Helper()
javaBin, err := exec.LookPath("java")
if err != nil {
t.Fatalf("look up java failed: %v", err)
}
javacBin, err := exec.LookPath("javac")
if err != nil {
t.Fatalf("look up javac failed: %v", err)
}
jarBin, err := exec.LookPath("jar")
if err != nil {
t.Fatalf("look up jar failed: %v", err)
}
classesDir := filepath.Join(t.TempDir(), "agent-fixture-classes")
sourceRoot := filepath.Join(testRepoRoot(t), "internal", "jvm", "testdata", "agentfixture", "src")
javaFiles, err := filepath.Glob(filepath.Join(sourceRoot, "com", "gonavi", "fixture", "*.java"))
if err != nil {
t.Fatalf("glob agent fixture sources failed: %v", err)
}
if len(javaFiles) == 0 {
t.Fatalf("expected agent fixture java files under %s", sourceRoot)
}
compileCmd := exec.Command(javacBin, append([]string{"-d", classesDir}, javaFiles...)...)
output, err := compileCmd.CombinedOutput()
if err != nil {
t.Fatalf("compile agent fixture failed: %v\n%s", err, strings.TrimSpace(string(output)))
}
manifestPath := filepath.Join(t.TempDir(), "agent-manifest.mf")
manifest := strings.Join([]string{
"Premain-Class: com.gonavi.fixture.GoNaviTestAgent",
"Agent-Class: com.gonavi.fixture.GoNaviTestAgent",
"Can-Redefine-Classes: false",
"Can-Retransform-Classes: false",
"",
}, "\n")
if err := os.WriteFile(manifestPath, []byte(manifest), 0o644); err != nil {
t.Fatalf("write agent manifest failed: %v", err)
}
agentJar := filepath.Join(t.TempDir(), "gonavi-test-agent.jar")
jarCmd := exec.Command(jarBin, "cmf", manifestPath, agentJar, "-C", classesDir, "com")
output, err = jarCmd.CombinedOutput()
if err != nil {
t.Fatalf("package agent jar failed: %v\n%s", err, strings.TrimSpace(string(output)))
}
port := reserveTCPPort(t)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
cmd := exec.CommandContext(
ctx,
javaBin,
fmt.Sprintf("-javaagent:%s=port=%d,token=secret-token", agentJar, port),
"-cp",
classesDir,
"com.gonavi.fixture.AgentHostApp",
)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("agent fixture stdout pipe failed: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("start agent fixture failed: %v", err)
}
t.Cleanup(func() {
cancel()
_ = cmd.Wait()
})
ready := make(chan error, 1)
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
if strings.TrimSpace(scanner.Text()) == "AGENT_READY" {
ready <- nil
return
}
}
if err := scanner.Err(); err != nil {
ready <- fmt.Errorf("agent fixture readiness read failed: %w", err)
return
}
ready <- fmt.Errorf("agent fixture terminated before readiness signal")
}()
select {
case err := <-ready:
if err != nil {
t.Fatalf("wait agent fixture ready failed: %v", err)
}
case <-time.After(20 * time.Second):
t.Fatal("agent fixture did not become ready within 20s")
}
waitForTest(t, 10*time.Second, func() error {
conn, dialErr := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 500*time.Millisecond)
if dialErr != nil {
return dialErr
}
_ = conn.Close()
return nil
})
return agentFixtureProcess{
port: port,
baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
cmd: cmd,
}
}
func newAgentProviderTestConfig(baseURL string, timeoutSeconds int) connection.ConnectionConfig {
readOnly := false
return connection.ConnectionConfig{
Type: "jvm",
Timeout: timeoutSeconds,
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
AllowedModes: []string{ModeAgent},
PreferredMode: ModeAgent,
Agent: connection.JVMAgentConfig{
BaseURL: baseURL,
APIKey: "secret-token",
TimeoutSeconds: timeoutSeconds,
},
},
}
}

View File

@@ -0,0 +1,206 @@
package jvm
import (
"fmt"
"strings"
"GoNavi-Wails/internal/connection"
)
func DescribeConnectionTestError(cfg connection.ConnectionConfig, err error) string {
if err == nil {
return ""
}
raw := strings.TrimSpace(err.Error())
if raw == "" {
return "JVM 连接失败"
}
switch strings.ToLower(strings.TrimSpace(cfg.JVM.PreferredMode)) {
case ModeJMX:
if mapped := describeJMXConnectionError(cfg, raw); mapped != "" {
return mapped
}
case ModeEndpoint:
if mapped := describeEndpointConnectionError(raw); mapped != "" {
return mapped
}
case ModeAgent:
if mapped := describeAgentConnectionError(raw); mapped != "" {
return mapped
}
}
return raw
}
func describeEndpointConnectionError(raw string) string {
lower := strings.ToLower(raw)
switch {
case strings.Contains(lower, "endpoint baseurl is required"):
return "Endpoint 连接失败:未填写 Endpoint Base URL。"
case strings.Contains(lower, "endpoint baseurl is invalid"):
return joinConnectionErrorMessage(
"Endpoint 连接失败Endpoint Base URL 格式不合法。",
"请填写完整的 `http://` 或 `https://` 地址,并指向实现 GoNavi JVM HTTP 合约的管理接口根路径,例如 `http://127.0.0.1:19090/manage/jvm`。",
raw,
)
case strings.Contains(lower, "endpoint scheme is unsupported"):
return joinConnectionErrorMessage(
"Endpoint 连接失败:当前只支持 HTTP 或 HTTPS 协议。",
"请把 Endpoint Base URL 改成 `http://` 或 `https://` 开头的地址。",
raw,
)
case strings.Contains(lower, "unexpected status: 404"), strings.Contains(lower, "request failed: 404"):
return joinConnectionErrorMessage(
"Endpoint 连接失败:目标地址已响应,但没有找到 GoNavi JVM 管理接口。",
"请确认 Base URL 指向的是 JVM 管理接口根路径,而不是普通业务接口、健康检查地址或网关首页。",
raw,
)
case strings.Contains(lower, "connect: connection refused"), strings.Contains(lower, "connection refused"):
return joinConnectionErrorMessage(
"Endpoint 连接失败:目标管理接口未监听,或当前地址不可达。",
"请确认 Base URL 指向实现 GoNavi JVM HTTP 合约的管理接口,并检查服务监听、端口映射和防火墙。",
raw,
)
case strings.Contains(lower, "401 unauthorized"), strings.Contains(lower, "missing or invalid api key"):
return joinConnectionErrorMessage(
"Endpoint 连接失败:目标管理接口已响应,但当前 API Key 无效或缺失。",
"请检查连接中的 Endpoint API Key 是否与目标服务配置一致。",
raw,
)
case strings.Contains(lower, "403 forbidden"):
return joinConnectionErrorMessage(
"Endpoint 连接失败:当前请求被目标管理接口拒绝。",
"请确认当前客户端来源、鉴权配置和访问策略允许 GoNavi 访问该管理接口。",
raw,
)
case strings.Contains(lower, "timed out"), strings.Contains(lower, "timeout"), strings.Contains(lower, "context deadline exceeded"), strings.Contains(lower, "i/o timeout"):
return joinConnectionErrorMessage(
"Endpoint 连接失败:访问目标管理接口超时。",
"请确认 Base URL 可达、目标服务已完成启动,并适当增加连接超时时间。",
raw,
)
default:
return ""
}
}
func describeAgentConnectionError(raw string) string {
lower := strings.ToLower(raw)
switch {
case strings.Contains(lower, "agent baseurl is required"):
return "Agent 连接失败:未填写 Agent Base URL。"
case strings.Contains(lower, "agent baseurl is invalid"):
return joinConnectionErrorMessage(
"Agent 连接失败Agent Base URL 格式不合法。",
"请填写完整的 `http://` 或 `https://` 地址,例如 `http://127.0.0.1:19090/gonavi/agent/jvm`。",
raw,
)
case strings.Contains(lower, "agent scheme is unsupported"):
return joinConnectionErrorMessage(
"Agent 连接失败:当前只支持 HTTP 或 HTTPS 协议。",
"请把 Agent Base URL 改成 `http://` 或 `https://` 开头的地址。",
raw,
)
case strings.Contains(lower, "connect: connection refused"), strings.Contains(lower, "connection refused"):
return joinConnectionErrorMessage(
"Agent 连接失败:目标 Agent 管理端口未监听,或当前地址不可达。",
"请确认 Java 服务已通过 `-javaagent` 启动 GoNavi Agent并检查 Base URL、端口映射和防火墙。",
raw,
)
case strings.Contains(lower, "401 unauthorized"), strings.Contains(lower, "missing or invalid api key"):
return joinConnectionErrorMessage(
"Agent 连接失败Agent 已响应,但当前 API Key 无效或缺失。",
"请检查连接中的 Agent API Key 是否与目标服务启动参数一致。",
raw,
)
case strings.Contains(lower, "403 forbidden"):
return joinConnectionErrorMessage(
"Agent 连接失败:当前请求被 Agent 拒绝。",
"请确认当前客户端来源、鉴权配置和 Agent 访问策略允许 GoNavi 访问。",
raw,
)
case strings.Contains(lower, "timed out"), strings.Contains(lower, "timeout"), strings.Contains(lower, "context deadline exceeded"), strings.Contains(lower, "i/o timeout"):
return joinConnectionErrorMessage(
"Agent 连接失败:访问 Agent 管理端口超时。",
"请确认目标地址可达、Agent 已完成启动,并适当增加连接超时时间。",
raw,
)
default:
return ""
}
}
func describeJMXConnectionError(cfg connection.ConnectionConfig, raw string) string {
lower := strings.ToLower(raw)
target := fmt.Sprintf("%s:%d", resolveJMXHost(cfg), resolveJMXPort(cfg))
switch {
case strings.Contains(lower, "jmx host is required"):
return "JMX 连接失败:未填写主机地址。"
case strings.Contains(lower, "jmx port is invalid"):
return "JMX 连接失败:端口无效,请填写 1-65535 之间的有效端口。"
case strings.Contains(lower, `required jmx helper dependency "java" not found`):
return joinConnectionErrorMessage(
"JMX 连接失败:当前机器未找到 `java` 运行时GoNavi 无法启动 JMX helper。",
"请先安装 JRE/JDK或通过环境变量 `GONAVI_JMX_JAVA_BIN` 指向正确的 `java` 可执行文件。",
raw,
)
case strings.Contains(lower, "non-jrmp server at remote endpoint"):
return joinConnectionErrorMessage(
fmt.Sprintf("JMX 连接失败:%s 不是标准 JMX 远程管理端口,当前更像普通业务端口或 HTTP 端口。", target),
"请改填应用实际暴露的 JMX 端口,而不是业务 `server.port`。如果服务只开启了 `-Dcom.sun.management.jmxremote`,但没有配置 `jmxremote.port`,也无法直接远程连接。",
raw,
)
case strings.Contains(lower, "no such object in table"):
return joinConnectionErrorMessage(
fmt.Sprintf("JMX 连接失败:%s 上虽然有 RMI 服务,但不是可用的 JMX RMIServer 端口。", target),
"这通常意味着填到了 RMI 注册端口、调试端口或其他 Java 服务端口。请检查 `jmxremote.port` 和 `jmxremote.rmi.port` 配置是否正确。",
raw,
)
case strings.Contains(lower, "connection reset"):
return joinConnectionErrorMessage(
fmt.Sprintf("JMX 连接失败:%s 上的服务主动断开了连接,当前端口不是兼容的标准 JMX RMI 端口。", target),
"请确认填写的是 JVM 真正对外暴露的 JMX 端口,而不是业务端口、调试端口或被代理转发的端口。",
raw,
)
case strings.Contains(lower, "connection refused"):
return joinConnectionErrorMessage(
fmt.Sprintf("JMX 连接失败:无法连接到 %s对应端口没有监听或当前网络不可达。", target),
"请确认目标 JVM 已开启远程 JMX并检查主机、防火墙、端口映射和 SSH/代理配置。",
raw,
)
case strings.Contains(lower, "authentication failed"), strings.Contains(lower, "securityexception"):
return joinConnectionErrorMessage(
fmt.Sprintf("JMX 连接失败:%s 需要认证,或当前凭据不可用。", target),
"请确认目标 JMX 是否关闭认证;如果必须认证,需要补充用户名/密码后再连接。",
raw,
)
case strings.Contains(lower, "timed out"), strings.Contains(lower, "timeout"), strings.Contains(lower, "context deadline exceeded"), strings.Contains(lower, "i/o timeout"):
return joinConnectionErrorMessage(
fmt.Sprintf("JMX 连接失败:连接 %s 超时。", target),
"请确认端口可达、网络未被拦截,并适当增加连接超时时间。",
raw,
)
default:
return ""
}
}
func joinConnectionErrorMessage(summary string, suggestion string, raw string) string {
lines := make([]string, 0, 3)
if trimmed := strings.TrimSpace(summary); trimmed != "" {
lines = append(lines, trimmed)
}
if trimmed := strings.TrimSpace(suggestion); trimmed != "" {
lines = append(lines, "建议:"+trimmed)
}
if trimmed := strings.TrimSpace(raw); trimmed != "" {
lines = append(lines, "技术细节:"+trimmed)
}
return strings.Join(lines, "\n")
}

View File

@@ -0,0 +1,237 @@
package jvm
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"GoNavi-Wails/internal/connection"
)
type DiagnosticEventSink func(chunk DiagnosticEventChunk)
type DiagnosticAgentBridgeTransport struct {
eventSink DiagnosticEventSink
}
type diagnosticRuntime struct {
contractRuntime
}
func NewDiagnosticAgentBridgeTransport() DiagnosticTransport {
return &DiagnosticAgentBridgeTransport{}
}
func (t *DiagnosticAgentBridgeTransport) SetEventSink(sink DiagnosticEventSink) {
t.eventSink = sink
}
func (t *DiagnosticAgentBridgeTransport) Mode() string {
return DiagnosticTransportAgentBridge
}
func (t *DiagnosticAgentBridgeTransport) TestConnection(ctx context.Context, cfg connection.ConnectionConfig) error {
runtime, err := newDiagnosticRuntime(cfg)
if err != nil {
return err
}
resp, err := doContractProbe(ctx, runtime.contractRuntime, http.MethodHead)
if err != nil {
return err
}
if resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented {
_ = resp.Body.Close()
resp, err = doContractProbe(ctx, runtime.contractRuntime, http.MethodGet)
if err != nil {
return err
}
}
defer resp.Body.Close()
if isReachableStatus(resp.StatusCode) {
return nil
}
return buildContractStatusError("diagnostic", "probe", resp)
}
func (t *DiagnosticAgentBridgeTransport) ProbeCapabilities(_ context.Context, cfg connection.ConnectionConfig) ([]DiagnosticCapability, error) {
if _, err := newDiagnosticRuntime(cfg); err != nil {
return nil, err
}
return []DiagnosticCapability{{
Transport: DiagnosticTransportAgentBridge,
CanOpenSession: true,
CanStream: true,
CanCancel: true,
AllowObserveCommands: cfg.JVM.Diagnostic.AllowObserveCommands,
AllowTraceCommands: cfg.JVM.Diagnostic.AllowTraceCommands,
AllowMutatingCommands: cfg.JVM.Diagnostic.AllowMutatingCommands,
}}, nil
}
func (t *DiagnosticAgentBridgeTransport) StartSession(ctx context.Context, cfg connection.ConnectionConfig, req DiagnosticSessionRequest) (DiagnosticSessionHandle, error) {
runtime, err := newDiagnosticRuntime(cfg)
if err != nil {
return DiagnosticSessionHandle{}, err
}
var handle DiagnosticSessionHandle
if err := runtime.doJSON(ctx, http.MethodPost, "start session", "sessions", nil, req, &handle); err != nil {
return DiagnosticSessionHandle{}, err
}
return handle, nil
}
func (t *DiagnosticAgentBridgeTransport) ExecuteCommand(ctx context.Context, cfg connection.ConnectionConfig, req DiagnosticCommandRequest) error {
runtime, err := newDiagnosticRuntime(cfg)
if err != nil {
return err
}
payload, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("diagnostic execute request encode failed: %w", err)
}
httpReq, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
runtime.resolveURL("commands", nil),
bytes.NewReader(payload),
)
if err != nil {
return fmt.Errorf("diagnostic execute request build failed: %w", err)
}
httpReq.Header.Set("Accept", "text/event-stream")
httpReq.Header.Set("Content-Type", "application/json")
if runtime.apiKey != "" {
httpReq.Header.Set("X-API-Key", runtime.apiKey)
}
resp, err := runtime.client.Do(httpReq)
if err != nil {
return wrapContractRequestError("diagnostic", "execute", runtime.timeout, err)
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return buildContractStatusError("diagnostic", "execute", resp)
}
return consumeDiagnosticSSE(resp.Body, t.eventSink)
}
func (t *DiagnosticAgentBridgeTransport) CancelCommand(ctx context.Context, cfg connection.ConnectionConfig, sessionID string, commandID string) error {
runtime, err := newDiagnosticRuntime(cfg)
if err != nil {
return err
}
return runtime.doJSON(ctx, http.MethodPost, "cancel command", "commands/cancel", nil, map[string]string{
"sessionId": sessionID,
"commandId": commandID,
}, nil)
}
func (t *DiagnosticAgentBridgeTransport) CloseSession(ctx context.Context, cfg connection.ConnectionConfig, sessionID string) error {
runtime, err := newDiagnosticRuntime(cfg)
if err != nil {
return err
}
return runtime.doJSON(ctx, http.MethodPost, "close session", "sessions/close", nil, map[string]string{
"sessionId": sessionID,
}, nil)
}
func consumeDiagnosticSSE(body io.Reader, sink DiagnosticEventSink) error {
scanner := bufio.NewScanner(body)
scanner.Buffer(make([]byte, 0, 16*1024), 1024*1024)
var eventName string
dataLines := make([]string, 0, 4)
flush := func() error {
if len(dataLines) == 0 {
eventName = ""
return nil
}
var chunk DiagnosticEventChunk
if err := json.Unmarshal([]byte(bytes.Join(stringSliceToBytes(dataLines), []byte("\n"))), &chunk); err != nil {
return fmt.Errorf("diagnostic sse decode failed: %w", err)
}
if chunk.Event == "" {
chunk.Event = eventName
}
if sink != nil {
sink(chunk)
}
eventName = ""
dataLines = dataLines[:0]
return nil
}
for scanner.Scan() {
line := scanner.Text()
if line == "" {
if err := flush(); err != nil {
return err
}
continue
}
switch {
case len(line) >= 6 && line[:6] == "event:":
eventName = string(bytes.TrimSpace([]byte(line[6:])))
case len(line) >= 5 && line[:5] == "data:":
dataLines = append(dataLines, string(bytes.TrimSpace([]byte(line[5:]))))
}
}
if err := scanner.Err(); err != nil {
return err
}
return flush()
}
func newDiagnosticRuntime(cfg connection.ConnectionConfig) (diagnosticRuntime, error) {
runtime, err := newContractRuntime(
cfg.JVM.Diagnostic.BaseURL,
cfg.JVM.Diagnostic.APIKey,
resolveDiagnosticTimeout(cfg),
"diagnostic",
)
if err != nil {
return diagnosticRuntime{}, err
}
return diagnosticRuntime{contractRuntime: runtime}, nil
}
func resolveDiagnosticTimeout(cfg connection.ConnectionConfig) time.Duration {
timeout := time.Duration(cfg.JVM.Diagnostic.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = time.Duration(cfg.Timeout) * time.Second
}
if timeout <= 0 {
timeout = 5 * time.Second
}
return timeout
}
func stringSliceToBytes(items []string) [][]byte {
result := make([][]byte, 0, len(items))
for _, item := range items {
result = append(result, []byte(item))
}
return result
}

View File

@@ -0,0 +1,134 @@
package jvm
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"GoNavi-Wails/internal/connection"
)
func TestDiagnosticAgentBridgeExecuteCommandStreamsChunks(t *testing.T) {
var commandRequest DiagnosticCommandRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/gonavi/diag/sessions":
if r.Method != http.MethodPost {
t.Fatalf("expected POST /sessions, got %s", r.Method)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"sessionId":"sess-1","transport":"agent-bridge","startedAt":1710000000}`))
case "/gonavi/diag/commands":
if r.Method != http.MethodPost {
t.Fatalf("expected POST /commands, got %s", r.Method)
}
if err := json.NewDecoder(r.Body).Decode(&commandRequest); err != nil {
t.Fatalf("decode command request failed: %v", err)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("event: chunk\ndata: {\"sessionId\":\"sess-1\",\"commandId\":\"cmd-1\",\"phase\":\"running\",\"content\":\"thread -n 5\"}\n\n"))
_, _ = w.Write([]byte("event: done\ndata: {\"sessionId\":\"sess-1\",\"commandId\":\"cmd-1\",\"phase\":\"completed\"}\n\n"))
default:
t.Fatalf("unexpected path: %s", r.URL.Path)
}
}))
defer server.Close()
cfg := connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: DiagnosticTransportAgentBridge,
BaseURL: server.URL + "/gonavi/diag",
TimeoutSeconds: 3,
},
},
}
transport := NewDiagnosticAgentBridgeTransport()
bridge, ok := transport.(*DiagnosticAgentBridgeTransport)
if !ok {
t.Fatalf("expected DiagnosticAgentBridgeTransport, got %T", transport)
}
var chunks []DiagnosticEventChunk
bridge.eventSink = func(chunk DiagnosticEventChunk) {
chunks = append(chunks, chunk)
}
handle, err := bridge.StartSession(context.Background(), cfg, DiagnosticSessionRequest{
Title: "线程诊断",
Reason: "排查线程堆积",
})
if err != nil {
t.Fatalf("StartSession returned error: %v", err)
}
if handle.SessionID != "sess-1" {
t.Fatalf("expected session id sess-1, got %#v", handle)
}
err = bridge.ExecuteCommand(context.Background(), cfg, DiagnosticCommandRequest{
SessionID: handle.SessionID,
CommandID: "cmd-1",
Command: "thread -n 5",
Source: "manual",
Reason: "定位线程堆积",
})
if err != nil {
t.Fatalf("ExecuteCommand returned error: %v", err)
}
if commandRequest.Command != "thread -n 5" || commandRequest.SessionID != "sess-1" {
t.Fatalf("unexpected command request payload: %#v", commandRequest)
}
if len(chunks) != 2 {
t.Fatalf("expected 2 chunks, got %#v", chunks)
}
if chunks[0].Phase != "running" || chunks[0].Content != "thread -n 5" {
t.Fatalf("unexpected first chunk: %#v", chunks[0])
}
if chunks[1].Phase != "completed" {
t.Fatalf("unexpected completion chunk: %#v", chunks[1])
}
}
func TestDiagnosticAgentBridgeCancelCommandSendsRequest(t *testing.T) {
var cancelPayload map[string]string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/gonavi/diag/commands/cancel" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.Method != http.MethodPost {
t.Fatalf("expected POST /commands/cancel, got %s", r.Method)
}
if err := json.NewDecoder(r.Body).Decode(&cancelPayload); err != nil {
t.Fatalf("decode cancel request failed: %v", err)
}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
cfg := connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: DiagnosticTransportAgentBridge,
BaseURL: server.URL + "/gonavi/diag",
TimeoutSeconds: 3,
},
},
}
transport := NewDiagnosticAgentBridgeTransport()
if err := transport.CancelCommand(context.Background(), cfg, "sess-1", "cmd-1"); err != nil {
t.Fatalf("CancelCommand returned error: %v", err)
}
if cancelPayload["sessionId"] != "sess-1" || cancelPayload["commandId"] != "cmd-1" {
t.Fatalf("unexpected cancel payload: %#v", cancelPayload)
}
}

View File

@@ -0,0 +1,591 @@
package jvm
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"GoNavi-Wails/internal/connection"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
const (
arthasTunnelDefaultCols = 160
arthasTunnelDefaultRows = 48
arthasTunnelReadStep = 250 * time.Millisecond
arthasTunnelPromptDetectionTail = 96
arthasTunnelInterruptInput = "\u0003"
)
var arthasPromptPattern = regexp.MustCompile(`\[arthas@[^\]]+\]\$ `)
type arthasTunnelTTYFrame struct {
Action string `json:"action"`
Data string `json:"data,omitempty"`
Cols int `json:"cols,omitempty"`
Rows int `json:"rows,omitempty"`
}
type DiagnosticArthasTunnelTransport struct {
eventSink DiagnosticEventSink
}
type arthasTunnelRuntime struct {
wsURL string
headers http.Header
timeout time.Duration
target string
}
type arthasTunnelSessionRegistry struct {
mu sync.Mutex
sessions map[string]arthasTunnelSessionMeta
active map[string]*arthasTunnelActiveCommand
}
type arthasTunnelSessionMeta struct {
createdAt int64
targetID string
baseURL string
}
type arthasTunnelActiveCommand struct {
commandID string
conn *websocket.Conn
mu sync.RWMutex
writeMu sync.Mutex
cancelRequested bool
}
var diagnosticArthasTunnelSessions = newArthasTunnelSessionRegistry()
func NewDiagnosticArthasTunnelTransport() DiagnosticTransport {
return &DiagnosticArthasTunnelTransport{}
}
func (t *DiagnosticArthasTunnelTransport) SetEventSink(sink DiagnosticEventSink) {
t.eventSink = sink
}
func (t *DiagnosticArthasTunnelTransport) Mode() string {
return DiagnosticTransportArthasTunnel
}
func (t *DiagnosticArthasTunnelTransport) TestConnection(ctx context.Context, cfg connection.ConnectionConfig) error {
runtime, err := newArthasTunnelRuntime(cfg)
if err != nil {
return err
}
commandCtx, cancel := context.WithTimeout(ctx, runtime.timeout)
defer cancel()
conn, err := runtime.dial(commandCtx)
if err != nil {
return err
}
defer conn.Close()
if err := runtime.writeFrame(conn, arthasTunnelTTYFrame{
Action: "resize",
Cols: arthasTunnelDefaultCols,
Rows: arthasTunnelDefaultRows,
}); err != nil {
return err
}
if _, err := runtime.waitForPrompt(commandCtx, conn); err != nil {
return err
}
return nil
}
func (t *DiagnosticArthasTunnelTransport) ProbeCapabilities(_ context.Context, cfg connection.ConnectionConfig) ([]DiagnosticCapability, error) {
if _, err := newArthasTunnelRuntime(cfg); err != nil {
return nil, err
}
return []DiagnosticCapability{{
Transport: DiagnosticTransportArthasTunnel,
CanOpenSession: true,
CanStream: true,
CanCancel: true,
AllowObserveCommands: cfg.JVM.Diagnostic.AllowObserveCommands,
AllowTraceCommands: cfg.JVM.Diagnostic.AllowTraceCommands,
AllowMutatingCommands: cfg.JVM.Diagnostic.AllowMutatingCommands,
}}, nil
}
func (t *DiagnosticArthasTunnelTransport) StartSession(_ context.Context, cfg connection.ConnectionConfig, _ DiagnosticSessionRequest) (DiagnosticSessionHandle, error) {
if _, err := newArthasTunnelRuntime(cfg); err != nil {
return DiagnosticSessionHandle{}, err
}
return diagnosticArthasTunnelSessions.createSession(cfg), nil
}
func (t *DiagnosticArthasTunnelTransport) ExecuteCommand(ctx context.Context, cfg connection.ConnectionConfig, req DiagnosticCommandRequest) error {
runtime, err := newArthasTunnelRuntime(cfg)
if err != nil {
return err
}
commandCtx, cancel := context.WithTimeout(ctx, runtime.timeout)
defer cancel()
activeCommand, err := diagnosticArthasTunnelSessions.beginCommand(req.SessionID, req.CommandID)
if err != nil {
return err
}
defer diagnosticArthasTunnelSessions.finishCommand(req.SessionID, req.CommandID)
conn, err := runtime.dial(commandCtx)
if err != nil {
return err
}
activeCommand.attachConn(conn)
defer conn.Close()
if err := activeCommand.send(arthasTunnelTTYFrame{
Action: "resize",
Cols: arthasTunnelDefaultCols,
Rows: arthasTunnelDefaultRows,
}); err != nil {
return err
}
if _, err := runtime.waitForPrompt(commandCtx, conn); err != nil {
return err
}
if err := activeCommand.send(arthasTunnelTTYFrame{
Action: "read",
Data: req.Command + "\r",
}); err != nil {
return err
}
return t.streamCommandUntilPrompt(commandCtx, runtime, activeCommand, req)
}
func (t *DiagnosticArthasTunnelTransport) CancelCommand(_ context.Context, _ connection.ConnectionConfig, sessionID string, commandID string) error {
return diagnosticArthasTunnelSessions.cancelCommand(sessionID, commandID)
}
func (t *DiagnosticArthasTunnelTransport) CloseSession(_ context.Context, _ connection.ConnectionConfig, sessionID string) error {
diagnosticArthasTunnelSessions.closeSession(sessionID)
return nil
}
func (t *DiagnosticArthasTunnelTransport) streamCommandUntilPrompt(
ctx context.Context,
runtime arthasTunnelRuntime,
activeCommand *arthasTunnelActiveCommand,
req DiagnosticCommandRequest,
) error {
pending := ""
for {
if ctx.Err() != nil {
return translateArthasTunnelContextError(ctx.Err(), runtime.timeout)
}
payload, err := runtime.readTextFrame(ctx, activeCommand.conn)
if err != nil {
return err
}
pending += payload
if promptIndex := arthasPromptPattern.FindStringIndex(pending); promptIndex != nil {
content := pending[:promptIndex[0]]
if strings.TrimSpace(content) != "" {
t.emitChunk(req, "running", content)
}
if activeCommand.isCancelRequested() || strings.Contains(content, "^C") {
t.emitChunk(req, "canceled", "Arthas 命令已取消")
return fmt.Errorf("arthas tunnel command canceled")
}
t.emitChunk(req, "completed", "Arthas 命令执行完成")
return nil
}
if len(pending) <= arthasTunnelPromptDetectionTail {
continue
}
emitText := pending[:len(pending)-arthasTunnelPromptDetectionTail]
pending = pending[len(pending)-arthasTunnelPromptDetectionTail:]
if strings.TrimSpace(emitText) != "" {
t.emitChunk(req, "running", emitText)
}
}
}
func (t *DiagnosticArthasTunnelTransport) emitChunk(req DiagnosticCommandRequest, phase string, content string) {
if t.eventSink == nil {
return
}
t.eventSink(DiagnosticEventChunk{
SessionID: req.SessionID,
CommandID: req.CommandID,
Event: "diagnostic",
Phase: phase,
Content: content,
Timestamp: time.Now().UnixMilli(),
Metadata: map[string]any{
"transport": DiagnosticTransportArthasTunnel,
},
})
}
func newArthasTunnelRuntime(cfg connection.ConnectionConfig) (arthasTunnelRuntime, error) {
baseURLText := strings.TrimSpace(cfg.JVM.Diagnostic.BaseURL)
if baseURLText == "" {
return arthasTunnelRuntime{}, errors.New("Arthas Tunnel 地址不能为空")
}
baseURL, err := url.Parse(baseURLText)
if err != nil || baseURL.Scheme == "" || baseURL.Host == "" {
return arthasTunnelRuntime{}, fmt.Errorf("Arthas Tunnel 地址格式不正确:%s", baseURLText)
}
targetID := strings.TrimSpace(cfg.JVM.Diagnostic.TargetID)
if targetID == "" {
return arthasTunnelRuntime{}, errors.New("Arthas Tunnel 需要填写目标实例标识targetId / agentId")
}
scheme := strings.ToLower(strings.TrimSpace(baseURL.Scheme))
switch scheme {
case "http":
baseURL.Scheme = "ws"
case "https":
baseURL.Scheme = "wss"
case "ws", "wss":
default:
return arthasTunnelRuntime{}, fmt.Errorf("Arthas Tunnel 仅支持 http/https/ws/wss 地址:%s", baseURL.Scheme)
}
baseURL.Path = resolveArthasTunnelWSPath(baseURL.Path)
query := baseURL.Query()
query.Set("method", "connectArthas")
query.Set("id", targetID)
baseURL.RawQuery = query.Encode()
headers := http.Header{}
if apiKey := strings.TrimSpace(cfg.JVM.Diagnostic.APIKey); apiKey != "" {
headers.Set("X-API-Key", apiKey)
}
return arthasTunnelRuntime{
wsURL: baseURL.String(),
headers: headers,
timeout: resolveDiagnosticTimeout(cfg),
target: targetID,
}, nil
}
func resolveArthasTunnelWSPath(path string) string {
trimmed := strings.TrimSpace(path)
switch {
case trimmed == "", trimmed == "/":
return "/ws"
case strings.HasSuffix(trimmed, "/ws"):
if strings.HasPrefix(trimmed, "/") {
return trimmed
}
return "/" + trimmed
case strings.HasSuffix(trimmed, "/"):
return strings.TrimRight(trimmed, "/") + "/ws"
case strings.HasPrefix(trimmed, "/"):
return trimmed + "/ws"
default:
return "/" + trimmed + "/ws"
}
}
func (r arthasTunnelRuntime) dial(ctx context.Context) (*websocket.Conn, error) {
dialer := websocket.Dialer{
HandshakeTimeout: r.timeout,
}
conn, resp, err := dialer.DialContext(ctx, r.wsURL, r.headers)
if err != nil {
if resp != nil {
defer resp.Body.Close()
return nil, fmt.Errorf("Arthas Tunnel 连接失败HTTP %s", resp.Status)
}
return nil, translateArthasTunnelIOError("建立 Arthas Tunnel WebSocket 连接", err, r.timeout)
}
return conn, nil
}
func (r arthasTunnelRuntime) waitForPrompt(ctx context.Context, conn *websocket.Conn) (string, error) {
pending := ""
for {
if ctx.Err() != nil {
return "", translateArthasTunnelContextError(ctx.Err(), r.timeout)
}
payload, err := r.readTextFrame(ctx, conn)
if err != nil {
return "", err
}
pending += payload
if promptIndex := arthasPromptPattern.FindStringIndex(pending); promptIndex != nil {
return pending[:promptIndex[0]], nil
}
}
}
func (r arthasTunnelRuntime) writeFrame(conn *websocket.Conn, frame arthasTunnelTTYFrame) error {
payload, err := json.Marshal(frame)
if err != nil {
return fmt.Errorf("Arthas Tunnel 请求编码失败:%w", err)
}
if err := conn.SetWriteDeadline(time.Now().Add(r.timeout)); err != nil {
return fmt.Errorf("Arthas Tunnel 写入超时设置失败:%w", err)
}
if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil {
return translateArthasTunnelIOError("向 Arthas Tunnel 发送终端指令", err, r.timeout)
}
return nil
}
func (r arthasTunnelRuntime) readTextFrame(ctx context.Context, conn *websocket.Conn) (string, error) {
for {
readDeadline := time.Now().Add(arthasTunnelReadStep)
if deadline, ok := ctx.Deadline(); ok && deadline.Before(readDeadline) {
readDeadline = deadline
}
if err := conn.SetReadDeadline(readDeadline); err != nil {
return "", fmt.Errorf("Arthas Tunnel 读取超时设置失败:%w", err)
}
messageType, payload, err := conn.ReadMessage()
if err != nil {
if isArthasTunnelTimeout(err) {
if ctx.Err() != nil {
return "", translateArthasTunnelContextError(ctx.Err(), r.timeout)
}
continue
}
return "", translateArthasTunnelReadError(err, r.timeout)
}
if messageType != websocket.TextMessage {
continue
}
return string(payload), nil
}
}
func translateArthasTunnelIOError(action string, err error, timeout time.Duration) error {
if errors.Is(err, context.DeadlineExceeded) || isArthasTunnelTimeout(err) {
return fmt.Errorf("%s超时%s 内未收到响应", action, timeout)
}
if errors.Is(err, context.Canceled) {
return fmt.Errorf("%s已取消", action)
}
return fmt.Errorf("%s失败%w", action, err)
}
func translateArthasTunnelReadError(err error, timeout time.Duration) error {
var closeErr *websocket.CloseError
if errors.As(err, &closeErr) {
if strings.TrimSpace(closeErr.Text) != "" {
return fmt.Errorf("Arthas Tunnel 连接已关闭:%s", translateArthasTunnelCloseReason(closeErr.Text))
}
return fmt.Errorf("Arthas Tunnel 连接已关闭code=%d", closeErr.Code)
}
return translateArthasTunnelIOError("读取 Arthas Tunnel 输出", err, timeout)
}
func translateArthasTunnelContextError(err error, timeout time.Duration) error {
if errors.Is(err, context.DeadlineExceeded) {
return fmt.Errorf("Arthas Tunnel 命令执行超时,%s 内未完成", timeout)
}
if errors.Is(err, context.Canceled) {
return errors.New("Arthas Tunnel 命令已取消")
}
return err
}
func isArthasTunnelTimeout(err error) bool {
var netErr net.Error
return errors.As(err, &netErr) && netErr.Timeout()
}
func translateArthasTunnelCloseReason(reason string) string {
trimmed := strings.TrimSpace(reason)
lowerReason := strings.ToLower(trimmed)
switch {
case strings.Contains(lowerReason, "can not find arthas agent by id"):
parts := strings.Split(trimmed, ":")
if len(parts) > 1 {
return "找不到目标实例 " + strings.TrimSpace(parts[len(parts)-1]) + ",请确认 targetId / agentId 是否填写正确,且对应 tunnel client 已在线"
}
return "找不到目标实例,请确认 targetId / agentId 是否填写正确,且对应 tunnel client 已在线"
case strings.Contains(lowerReason, "arthas agent id can not be null"):
return "缺少目标实例标识,请填写 targetId / agentId"
default:
return trimmed
}
}
func newArthasTunnelSessionRegistry() *arthasTunnelSessionRegistry {
return &arthasTunnelSessionRegistry{
sessions: make(map[string]arthasTunnelSessionMeta),
active: make(map[string]*arthasTunnelActiveCommand),
}
}
func (r *arthasTunnelSessionRegistry) createSession(cfg connection.ConnectionConfig) DiagnosticSessionHandle {
r.mu.Lock()
defer r.mu.Unlock()
sessionID := "arthas-" + uuid.NewString()
startedAt := time.Now().UnixMilli()
r.sessions[sessionID] = arthasTunnelSessionMeta{
createdAt: startedAt,
targetID: strings.TrimSpace(cfg.JVM.Diagnostic.TargetID),
baseURL: strings.TrimSpace(cfg.JVM.Diagnostic.BaseURL),
}
return DiagnosticSessionHandle{
SessionID: sessionID,
Transport: DiagnosticTransportArthasTunnel,
StartedAt: startedAt,
}
}
func (r *arthasTunnelSessionRegistry) beginCommand(sessionID string, commandID string) (*arthasTunnelActiveCommand, error) {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.sessions[sessionID]; !ok {
return nil, errors.New("诊断会话不存在,请重新创建 Arthas Tunnel 会话")
}
if existing := r.active[sessionID]; existing != nil {
return nil, errors.New("当前 Arthas Tunnel 会话已有命令在执行,请先等待完成或取消")
}
activeCommand := &arthasTunnelActiveCommand{commandID: commandID}
r.active[sessionID] = activeCommand
return activeCommand, nil
}
func (r *arthasTunnelSessionRegistry) finishCommand(sessionID string, commandID string) {
r.mu.Lock()
activeCommand := r.active[sessionID]
if activeCommand != nil && activeCommand.commandID == commandID {
delete(r.active, sessionID)
}
r.mu.Unlock()
if activeCommand != nil && activeCommand.commandID == commandID {
activeCommand.close()
}
}
func (r *arthasTunnelSessionRegistry) cancelCommand(sessionID string, commandID string) error {
r.mu.Lock()
activeCommand := r.active[sessionID]
r.mu.Unlock()
if activeCommand == nil {
return errors.New("当前 Arthas Tunnel 会话没有正在执行的命令")
}
if activeCommand.commandID != commandID {
return errors.New("当前 Arthas Tunnel 会话的活动命令与待取消命令不一致")
}
return activeCommand.requestCancel()
}
func (r *arthasTunnelSessionRegistry) closeSession(sessionID string) {
r.mu.Lock()
activeCommand := r.active[sessionID]
delete(r.active, sessionID)
delete(r.sessions, sessionID)
r.mu.Unlock()
if activeCommand != nil {
activeCommand.close()
}
}
func (c *arthasTunnelActiveCommand) attachConn(conn *websocket.Conn) {
c.mu.Lock()
defer c.mu.Unlock()
c.conn = conn
}
func (c *arthasTunnelActiveCommand) send(frame arthasTunnelTTYFrame) error {
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil {
return errors.New("Arthas Tunnel 连接尚未建立完成,请稍后重试")
}
payload, err := json.Marshal(frame)
if err != nil {
return fmt.Errorf("Arthas Tunnel 终端指令编码失败:%w", err)
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
if err := conn.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil {
return fmt.Errorf("Arthas Tunnel 写入超时设置失败:%w", err)
}
if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil {
return fmt.Errorf("向 Arthas Tunnel 发送终端指令失败:%w", err)
}
return nil
}
func (c *arthasTunnelActiveCommand) requestCancel() error {
c.mu.Lock()
c.cancelRequested = true
c.mu.Unlock()
return c.send(arthasTunnelTTYFrame{
Action: "read",
Data: arthasTunnelInterruptInput,
})
}
func (c *arthasTunnelActiveCommand) isCancelRequested() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.cancelRequested
}
func (c *arthasTunnelActiveCommand) close() {
c.mu.Lock()
conn := c.conn
c.conn = nil
c.mu.Unlock()
if conn != nil {
_ = conn.Close()
}
}

View File

@@ -0,0 +1,376 @@
package jvm
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"GoNavi-Wails/internal/connection"
"github.com/gorilla/websocket"
)
type fakeArthasTTYFrame struct {
Action string `json:"action"`
Data string `json:"data,omitempty"`
Cols int `json:"cols,omitempty"`
Rows int `json:"rows,omitempty"`
}
type fakeArthasTunnelServer struct {
t *testing.T
server *httptest.Server
upgrader websocket.Upgrader
onFrame func(*websocket.Conn, fakeArthasTTYFrame)
mu sync.Mutex
queries []url.Values
frames []fakeArthasTTYFrame
connectionIDs []string
}
func newFakeArthasTunnelServer(
t *testing.T,
onFrame func(*websocket.Conn, fakeArthasTTYFrame),
) *fakeArthasTunnelServer {
t.Helper()
fake := &fakeArthasTunnelServer{
t: t,
upgrader: websocket.Upgrader{
CheckOrigin: func(*http.Request) bool { return true },
},
onFrame: onFrame,
}
fake.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/ws" {
t.Fatalf("unexpected websocket path: %s", r.URL.Path)
}
conn, err := fake.upgrader.Upgrade(w, r, nil)
if err != nil {
t.Fatalf("upgrade failed: %v", err)
}
fake.mu.Lock()
fake.queries = append(fake.queries, r.URL.Query())
fake.connectionIDs = append(fake.connectionIDs, r.URL.Query().Get("id"))
fake.mu.Unlock()
if err := conn.WriteMessage(websocket.TextMessage, []byte("Welcome to Arthas!\r\n[arthas@12345]$ ")); err != nil {
t.Fatalf("write welcome prompt failed: %v", err)
}
for {
_, payload, err := conn.ReadMessage()
if err != nil {
return
}
var frame fakeArthasTTYFrame
if err := json.Unmarshal(payload, &frame); err != nil {
t.Fatalf("decode tty frame failed: %v", err)
}
fake.mu.Lock()
fake.frames = append(fake.frames, frame)
fake.mu.Unlock()
if fake.onFrame != nil {
fake.onFrame(conn, frame)
}
}
}))
return fake
}
func (s *fakeArthasTunnelServer) close() {
if s.server != nil {
s.server.Close()
}
}
func (s *fakeArthasTunnelServer) wsURL() string {
return s.server.URL
}
func (s *fakeArthasTunnelServer) queriesSnapshot() []url.Values {
s.mu.Lock()
defer s.mu.Unlock()
result := make([]url.Values, 0, len(s.queries))
for _, item := range s.queries {
cloned := url.Values{}
for key, values := range item {
cloned[key] = append([]string(nil), values...)
}
result = append(result, cloned)
}
return result
}
func (s *fakeArthasTunnelServer) framesSnapshot() []fakeArthasTTYFrame {
s.mu.Lock()
defer s.mu.Unlock()
return append([]fakeArthasTTYFrame(nil), s.frames...)
}
func testArthasTunnelConfig(baseURL string) connection.ConnectionConfig {
return connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: DiagnosticTransportArthasTunnel,
BaseURL: baseURL,
TargetID: "orders-prod-01",
TimeoutSeconds: 3,
},
},
}
}
func TestDiagnosticArthasTunnelExecuteCommandStreamsUntilPrompt(t *testing.T) {
commandSeen := make(chan struct{}, 1)
server := newFakeArthasTunnelServer(t, func(conn *websocket.Conn, frame fakeArthasTTYFrame) {
if frame.Action != "read" {
return
}
if strings.Contains(frame.Data, "thread -n 5") {
commandSeen <- struct{}{}
_ = conn.WriteMessage(websocket.TextMessage, []byte("thread top 5\r\nworker-1 cpu=87%\r\n"))
_ = conn.WriteMessage(websocket.TextMessage, []byte("[arthas@12345]$ "))
}
})
defer server.close()
transport, err := NewDiagnosticTransport(DiagnosticTransportArthasTunnel)
if err != nil {
t.Fatalf("NewDiagnosticTransport returned error: %v", err)
}
tunnel, ok := transport.(*DiagnosticArthasTunnelTransport)
if !ok {
t.Fatalf("expected DiagnosticArthasTunnelTransport, got %T", transport)
}
cfg := testArthasTunnelConfig(server.wsURL())
if err := tunnel.TestConnection(context.Background(), cfg); err != nil {
t.Fatalf("TestConnection returned error: %v", err)
}
handle, err := tunnel.StartSession(context.Background(), cfg, DiagnosticSessionRequest{
Title: "线程诊断",
Reason: "排查线程堆积",
})
if err != nil {
t.Fatalf("StartSession returned error: %v", err)
}
if handle.Transport != DiagnosticTransportArthasTunnel {
t.Fatalf("expected arthas-tunnel handle, got %#v", handle)
}
if handle.SessionID == "" {
t.Fatalf("expected synthetic session id, got %#v", handle)
}
var chunks []DiagnosticEventChunk
tunnel.SetEventSink(func(chunk DiagnosticEventChunk) {
chunks = append(chunks, chunk)
})
if err := tunnel.ExecuteCommand(context.Background(), cfg, DiagnosticCommandRequest{
SessionID: handle.SessionID,
CommandID: "cmd-1",
Command: "thread -n 5",
Source: "manual",
Reason: "定位线程热点",
}); err != nil {
t.Fatalf("ExecuteCommand returned error: %v", err)
}
select {
case <-commandSeen:
case <-time.After(2 * time.Second):
t.Fatal("expected tunnel server to receive arthas command")
}
queries := server.queriesSnapshot()
if len(queries) < 2 {
t.Fatalf("expected connection probe and command websocket handshakes, got %#v", queries)
}
lastQuery := queries[len(queries)-1]
if lastQuery.Get("method") != "connectArthas" {
t.Fatalf("expected connectArthas handshake, got %#v", lastQuery)
}
if lastQuery.Get("id") != "orders-prod-01" {
t.Fatalf("expected target id orders-prod-01, got %#v", lastQuery)
}
frames := server.framesSnapshot()
if len(frames) == 0 {
t.Fatal("expected websocket tty frames to be sent")
}
if frames[0].Action != "resize" {
t.Fatalf("expected first tty frame to resize terminal, got %#v", frames[0])
}
if len(chunks) < 2 {
t.Fatalf("expected running and completed chunks, got %#v", chunks)
}
if chunks[0].Phase != "running" {
t.Fatalf("expected first chunk phase running, got %#v", chunks[0])
}
if !strings.Contains(chunks[0].Content, "thread top 5") {
t.Fatalf("expected arthas output in running chunk, got %#v", chunks[0])
}
if chunks[len(chunks)-1].Phase != "completed" {
t.Fatalf("expected terminal chunk completed, got %#v", chunks[len(chunks)-1])
}
}
func TestDiagnosticArthasTunnelCancelCommandInterruptsActiveCommand(t *testing.T) {
commandStarted := make(chan struct{}, 1)
cancelSeen := make(chan struct{}, 1)
server := newFakeArthasTunnelServer(t, func(conn *websocket.Conn, frame fakeArthasTTYFrame) {
if frame.Action != "read" {
return
}
switch {
case strings.Contains(frame.Data, "watch com.foo.OrderService submitOrder"):
commandStarted <- struct{}{}
_ = conn.WriteMessage(websocket.TextMessage, []byte("Press Ctrl+C to abort.\r\ntrace running...\r\n"))
case strings.Contains(frame.Data, string([]byte{3})):
cancelSeen <- struct{}{}
_ = conn.WriteMessage(websocket.TextMessage, []byte("^C\r\n[arthas@12345]$ "))
}
})
defer server.close()
transport, err := NewDiagnosticTransport(DiagnosticTransportArthasTunnel)
if err != nil {
t.Fatalf("NewDiagnosticTransport returned error: %v", err)
}
tunnel := transport.(*DiagnosticArthasTunnelTransport)
cfg := testArthasTunnelConfig(server.wsURL())
handle, err := tunnel.StartSession(context.Background(), cfg, DiagnosticSessionRequest{
Title: "长命令诊断",
Reason: "验证取消链路",
})
if err != nil {
t.Fatalf("StartSession returned error: %v", err)
}
var chunks []DiagnosticEventChunk
tunnel.SetEventSink(func(chunk DiagnosticEventChunk) {
chunks = append(chunks, chunk)
})
errCh := make(chan error, 1)
go func() {
errCh <- tunnel.ExecuteCommand(context.Background(), cfg, DiagnosticCommandRequest{
SessionID: handle.SessionID,
CommandID: "cmd-long",
Command: "watch com.foo.OrderService submitOrder",
Source: "manual",
Reason: "验证中断",
})
}()
select {
case <-commandStarted:
case <-time.After(2 * time.Second):
t.Fatal("expected long-running command to start")
}
if err := tunnel.CancelCommand(context.Background(), cfg, handle.SessionID, "cmd-long"); err != nil {
t.Fatalf("CancelCommand returned error: %v", err)
}
select {
case <-cancelSeen:
case <-time.After(2 * time.Second):
t.Fatal("expected ctrl+c interrupt frame to reach tunnel server")
}
select {
case err := <-errCh:
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "canceled") {
t.Fatalf("expected canceled error, got %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("expected ExecuteCommand to exit after cancellation")
}
if len(chunks) == 0 {
t.Fatal("expected cancel flow to emit chunks")
}
}
func TestDiagnosticArthasTunnelRequiresTargetID(t *testing.T) {
transport, err := NewDiagnosticTransport(DiagnosticTransportArthasTunnel)
if err != nil {
t.Fatalf("NewDiagnosticTransport returned error: %v", err)
}
cfg := testArthasTunnelConfig("http://127.0.0.1:7777")
cfg.JVM.Diagnostic.TargetID = ""
err = transport.TestConnection(context.Background(), cfg)
if err == nil {
t.Fatal("expected missing targetId to be rejected")
}
if !strings.Contains(err.Error(), "target") {
t.Fatalf("expected targetId error, got %v", err)
}
}
func TestDiagnosticArthasTunnelProbeCapabilitiesAndCloseSession(t *testing.T) {
transport, err := NewDiagnosticTransport(DiagnosticTransportArthasTunnel)
if err != nil {
t.Fatalf("NewDiagnosticTransport returned error: %v", err)
}
cfg := testArthasTunnelConfig("http://127.0.0.1:7777")
capabilities, err := transport.ProbeCapabilities(context.Background(), cfg)
if err != nil {
t.Fatalf("ProbeCapabilities returned error: %v", err)
}
if len(capabilities) != 1 {
t.Fatalf("expected a single capability payload, got %#v", capabilities)
}
if !capabilities[0].CanOpenSession || !capabilities[0].CanStream || !capabilities[0].CanCancel {
t.Fatalf("unexpected arthas-tunnel capabilities: %#v", capabilities[0])
}
handle, err := transport.StartSession(context.Background(), cfg, DiagnosticSessionRequest{})
if err != nil {
t.Fatalf("StartSession returned error: %v", err)
}
if err := transport.CloseSession(context.Background(), cfg, handle.SessionID); err != nil {
t.Fatalf("CloseSession returned error: %v", err)
}
err = transport.ExecuteCommand(context.Background(), cfg, DiagnosticCommandRequest{
SessionID: handle.SessionID,
CommandID: "cmd-after-close",
Command: "thread -n 1",
})
if err == nil {
t.Fatal("expected closed synthetic session to reject command execution")
}
if !strings.Contains(err.Error(), "诊断会话不存在") {
t.Fatalf("expected closed-session error, got %v", err)
}
}

View File

@@ -0,0 +1,86 @@
package jvm
import (
"bufio"
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
type DiagnosticAuditStore struct {
path string
}
func NewDiagnosticAuditStore(path string) *DiagnosticAuditStore {
return &DiagnosticAuditStore{path: path}
}
func (s *DiagnosticAuditStore) Append(record DiagnosticAuditRecord) error {
if strings.TrimSpace(s.path) == "" {
return errors.New("diagnostic audit store path is empty")
}
if record.Timestamp == 0 {
record.Timestamp = time.Now().UnixMilli()
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer file.Close()
return json.NewEncoder(file).Encode(record)
}
func (s *DiagnosticAuditStore) List(connectionID string, limit int) ([]DiagnosticAuditRecord, error) {
if strings.TrimSpace(s.path) == "" {
return nil, errors.New("diagnostic audit store path is empty")
}
file, err := os.Open(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []DiagnosticAuditRecord{}, nil
}
return nil, err
}
defer file.Close()
normalizedConnectionID := strings.TrimSpace(connectionID)
records := make([]DiagnosticAuditRecord, 0, 16)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var record DiagnosticAuditRecord
if err := json.Unmarshal([]byte(line), &record); err != nil {
return nil, err
}
if normalizedConnectionID != "" && strings.TrimSpace(record.ConnectionID) != normalizedConnectionID {
continue
}
records = append(records, record)
}
if err := scanner.Err(); err != nil {
return nil, err
}
sort.SliceStable(records, func(i, j int) bool {
return records[i].Timestamp > records[j].Timestamp
})
if limit > 0 && len(records) > limit {
return records[:limit], nil
}
return records, nil
}

View File

@@ -0,0 +1,37 @@
package jvm
import (
"path/filepath"
"testing"
)
func TestDiagnosticAuditStoreAppendAndList(t *testing.T) {
store := NewDiagnosticAuditStore(filepath.Join(t.TempDir(), "diagnostic_audit.jsonl"))
err := store.Append(DiagnosticAuditRecord{
ConnectionID: "conn-orders",
Transport: DiagnosticTransportAgentBridge,
SessionID: "sess-1",
CommandID: "cmd-1",
Command: "thread -n 5",
CommandType: DiagnosticCommandCategoryObserve,
RiskLevel: "low",
Status: "completed",
Reason: "排查线程堆积",
})
if err != nil {
t.Fatalf("Append returned error: %v", err)
}
records, err := store.List("conn-orders", 10)
if err != nil {
t.Fatalf("List returned error: %v", err)
}
if len(records) != 1 {
t.Fatalf("expected 1 record, got %#v", records)
}
record := records[0]
if record.Command != "thread -n 5" || record.Status != "completed" {
t.Fatalf("unexpected diagnostic audit record: %#v", record)
}
}

View File

@@ -0,0 +1,109 @@
package jvm
import (
"fmt"
"strings"
"GoNavi-Wails/internal/connection"
)
const defaultDiagnosticTimeoutSeconds = 15
var observeDiagnosticCommands = map[string]struct{}{
"dashboard": {},
"thread": {},
"sc": {},
"sm": {},
"jad": {},
"sysprop": {},
"sysenv": {},
"classloader": {},
}
var traceDiagnosticCommands = map[string]struct{}{
"trace": {},
"watch": {},
"stack": {},
"monitor": {},
"tt": {},
}
func NormalizeDiagnosticConfig(cfg connection.ConnectionConfig) (connection.JVMDiagnosticConfig, error) {
if strings.ToLower(strings.TrimSpace(cfg.Type)) != "jvm" {
return connection.JVMDiagnosticConfig{}, fmt.Errorf("unexpected connection type: %s", cfg.Type)
}
normalized := cfg.JVM.Diagnostic
normalized.Transport = normalizeDiagnosticTransport(normalized.Transport)
if normalized.Transport == "" {
return connection.JVMDiagnosticConfig{}, fmt.Errorf("不支持的 JVM 诊断传输模式:%s", strings.TrimSpace(cfg.JVM.Diagnostic.Transport))
}
normalized.BaseURL = strings.TrimSpace(normalized.BaseURL)
normalized.TargetID = strings.TrimSpace(normalized.TargetID)
normalized.APIKey = strings.TrimSpace(normalized.APIKey)
if normalized.TimeoutSeconds <= 0 {
normalized.TimeoutSeconds = defaultDiagnosticTimeoutSeconds
}
if !normalized.AllowObserveCommands && !normalized.AllowTraceCommands && !normalized.AllowMutatingCommands {
normalized.AllowObserveCommands = true
}
return normalized, nil
}
func ValidateDiagnosticCommandPolicy(cfg connection.JVMDiagnosticConfig, command string) (string, error) {
if !cfg.Enabled {
return "", fmt.Errorf("当前连接未启用 JVM 诊断增强模式")
}
category, normalizedCommand, err := classifyDiagnosticCommand(command)
if err != nil {
return "", err
}
switch category {
case DiagnosticCommandCategoryObserve:
if !cfg.AllowObserveCommands {
return "", fmt.Errorf("当前连接未开放观察类诊断命令:%s", normalizedCommand)
}
case DiagnosticCommandCategoryTrace:
if !cfg.AllowTraceCommands {
return "", fmt.Errorf("当前连接未开放跟踪类诊断命令:%s", normalizedCommand)
}
default:
if !cfg.AllowMutatingCommands {
return "", fmt.Errorf("当前连接未开放高风险诊断命令:%s", normalizedCommand)
}
}
return category, nil
}
func classifyDiagnosticCommand(command string) (string, string, error) {
normalizedCommand := strings.TrimSpace(command)
if normalizedCommand == "" {
return "", "", fmt.Errorf("诊断命令不能为空")
}
fields := strings.Fields(strings.ToLower(normalizedCommand))
head := fields[0]
if _, ok := observeDiagnosticCommands[head]; ok {
return DiagnosticCommandCategoryObserve, normalizedCommand, nil
}
if _, ok := traceDiagnosticCommands[head]; ok {
return DiagnosticCommandCategoryTrace, normalizedCommand, nil
}
return DiagnosticCommandCategoryMutating, normalizedCommand, nil
}
func normalizeDiagnosticTransport(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", DiagnosticTransportAgentBridge:
return DiagnosticTransportAgentBridge
case DiagnosticTransportArthasTunnel:
return DiagnosticTransportArthasTunnel
default:
return ""
}
}

View File

@@ -0,0 +1,53 @@
package jvm
import (
"testing"
"GoNavi-Wails/internal/connection"
)
func TestNormalizeDiagnosticConfigDefaultsToDisabledObserveOnly(t *testing.T) {
cfg, err := NormalizeDiagnosticConfig(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{},
})
if err != nil {
t.Fatalf("NormalizeDiagnosticConfig returned error: %v", err)
}
if cfg.Enabled {
t.Fatalf("expected diagnostic mode disabled by default")
}
if cfg.Transport != DiagnosticTransportAgentBridge {
t.Fatalf("expected default transport %q, got %q", DiagnosticTransportAgentBridge, cfg.Transport)
}
if cfg.TimeoutSeconds != 15 {
t.Fatalf("expected default timeout 15 seconds, got %d", cfg.TimeoutSeconds)
}
if !cfg.AllowObserveCommands || cfg.AllowTraceCommands || cfg.AllowMutatingCommands {
t.Fatalf("unexpected default command policy: %#v", cfg)
}
}
func TestClassifyDiagnosticCommandRejectsMutatingCommandWhenDisabled(t *testing.T) {
cfg, err := NormalizeDiagnosticConfig(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
AllowObserveCommands: true,
},
},
})
if err != nil {
t.Fatalf("NormalizeDiagnosticConfig returned error: %v", err)
}
_, err = ValidateDiagnosticCommandPolicy(cfg, "ognl '@java.lang.System@exit(0)'")
if err == nil {
t.Fatalf("expected mutating command to be rejected")
}
}

View File

@@ -0,0 +1,71 @@
package jvm
import (
"context"
"fmt"
"strings"
"GoNavi-Wails/internal/connection"
)
type DiagnosticTransport interface {
Mode() string
TestConnection(ctx context.Context, cfg connection.ConnectionConfig) error
ProbeCapabilities(ctx context.Context, cfg connection.ConnectionConfig) ([]DiagnosticCapability, error)
StartSession(ctx context.Context, cfg connection.ConnectionConfig, req DiagnosticSessionRequest) (DiagnosticSessionHandle, error)
ExecuteCommand(ctx context.Context, cfg connection.ConnectionConfig, req DiagnosticCommandRequest) error
CancelCommand(ctx context.Context, cfg connection.ConnectionConfig, sessionID string, commandID string) error
CloseSession(ctx context.Context, cfg connection.ConnectionConfig, sessionID string) error
}
type diagnosticTransportNotImplemented struct {
mode string
}
func (t diagnosticTransportNotImplemented) Mode() string { return t.mode }
func (t diagnosticTransportNotImplemented) TestConnection(context.Context, connection.ConnectionConfig) error {
return errDiagnosticTransportNotImplemented(t.mode, "test connection")
}
func (t diagnosticTransportNotImplemented) ProbeCapabilities(context.Context, connection.ConnectionConfig) ([]DiagnosticCapability, error) {
return nil, errDiagnosticTransportNotImplemented(t.mode, "probe capabilities")
}
func (t diagnosticTransportNotImplemented) StartSession(context.Context, connection.ConnectionConfig, DiagnosticSessionRequest) (DiagnosticSessionHandle, error) {
return DiagnosticSessionHandle{}, errDiagnosticTransportNotImplemented(t.mode, "start session")
}
func (t diagnosticTransportNotImplemented) ExecuteCommand(context.Context, connection.ConnectionConfig, DiagnosticCommandRequest) error {
return errDiagnosticTransportNotImplemented(t.mode, "execute command")
}
func (t diagnosticTransportNotImplemented) CancelCommand(context.Context, connection.ConnectionConfig, string, string) error {
return errDiagnosticTransportNotImplemented(t.mode, "cancel command")
}
func (t diagnosticTransportNotImplemented) CloseSession(context.Context, connection.ConnectionConfig, string) error {
return errDiagnosticTransportNotImplemented(t.mode, "close session")
}
var diagnosticTransportFactories = map[string]func() DiagnosticTransport{
DiagnosticTransportAgentBridge: func() DiagnosticTransport {
return NewDiagnosticAgentBridgeTransport()
},
DiagnosticTransportArthasTunnel: func() DiagnosticTransport {
return NewDiagnosticArthasTunnelTransport()
},
}
func NewDiagnosticTransport(mode string) (DiagnosticTransport, error) {
normalizedMode := strings.ToLower(strings.TrimSpace(mode))
factory, ok := diagnosticTransportFactories[normalizedMode]
if !ok {
return nil, fmt.Errorf("unsupported diagnostic transport: %s", mode)
}
return factory(), nil
}
func errDiagnosticTransportNotImplemented(mode string, action string) error {
return fmt.Errorf("%s diagnostic transport does not implement %s yet", strings.TrimSpace(mode), action)
}

View File

@@ -0,0 +1,66 @@
package jvm
const (
DiagnosticTransportAgentBridge = "agent-bridge"
DiagnosticTransportArthasTunnel = "arthas-tunnel"
)
const (
DiagnosticCommandCategoryObserve = "observe"
DiagnosticCommandCategoryTrace = "trace"
DiagnosticCommandCategoryMutating = "mutating"
)
type DiagnosticCapability struct {
Transport string `json:"transport"`
CanOpenSession bool `json:"canOpenSession"`
CanStream bool `json:"canStream"`
CanCancel bool `json:"canCancel"`
AllowObserveCommands bool `json:"allowObserveCommands"`
AllowTraceCommands bool `json:"allowTraceCommands"`
AllowMutatingCommands bool `json:"allowMutatingCommands"`
Reason string `json:"reason,omitempty"`
}
type DiagnosticSessionRequest struct {
Title string `json:"title,omitempty"`
Reason string `json:"reason,omitempty"`
}
type DiagnosticSessionHandle struct {
SessionID string `json:"sessionId"`
Transport string `json:"transport"`
StartedAt int64 `json:"startedAt"`
}
type DiagnosticCommandRequest struct {
SessionID string `json:"sessionId"`
CommandID string `json:"commandId"`
Command string `json:"command"`
Source string `json:"source,omitempty"`
Reason string `json:"reason,omitempty"`
}
type DiagnosticEventChunk struct {
SessionID string `json:"sessionId"`
CommandID string `json:"commandId,omitempty"`
Event string `json:"event,omitempty"`
Phase string `json:"phase,omitempty"`
Content string `json:"content,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type DiagnosticAuditRecord struct {
Timestamp int64 `json:"timestamp"`
ConnectionID string `json:"connectionId"`
SessionID string `json:"sessionId,omitempty"`
CommandID string `json:"commandId,omitempty"`
Transport string `json:"transport"`
Command string `json:"command"`
CommandType string `json:"commandType,omitempty"`
Source string `json:"source,omitempty"`
Reason string `json:"reason,omitempty"`
RiskLevel string `json:"riskLevel,omitempty"`
Status string `json:"status"`
}

View File

@@ -0,0 +1,195 @@
package jvm
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
type contractRuntime struct {
baseURL *url.URL
apiKey string
client *http.Client
timeout time.Duration
errorPrefix string
}
func newContractRuntime(baseURLText string, apiKey string, timeout time.Duration, errorPrefix string) (contractRuntime, error) {
baseURL, err := normalizeContractBaseURL(baseURLText, errorPrefix)
if err != nil {
return contractRuntime{}, err
}
if timeout <= 0 {
timeout = 5 * time.Second
}
return contractRuntime{
baseURL: baseURL,
apiKey: strings.TrimSpace(apiKey),
client: &http.Client{Timeout: timeout},
timeout: timeout,
errorPrefix: strings.TrimSpace(errorPrefix),
}, nil
}
func normalizeContractBaseURL(rawBaseURL string, errorPrefix string) (*url.URL, error) {
baseURL := strings.TrimSpace(rawBaseURL)
if baseURL == "" {
return nil, fmt.Errorf("%s baseURL is required", errorPrefix)
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, fmt.Errorf("%s baseURL is invalid: %s", errorPrefix, baseURL)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, fmt.Errorf("%s scheme is unsupported: %s", errorPrefix, parsed.Scheme)
}
return parsed, nil
}
func (r contractRuntime) doJSON(
ctx context.Context,
method string,
action string,
relativePath string,
query url.Values,
requestBody any,
out any,
) error {
var bodyReader io.Reader
if requestBody != nil {
payload, err := json.Marshal(requestBody)
if err != nil {
return fmt.Errorf("%s %s request encode failed: %w", r.errorPrefix, action, err)
}
bodyReader = bytes.NewReader(payload)
}
req, err := http.NewRequestWithContext(ctx, method, r.resolveURL(relativePath, query), bodyReader)
if err != nil {
return fmt.Errorf("%s %s request build failed: %w", r.errorPrefix, action, err)
}
req.Header.Set("Accept", "application/json")
if r.apiKey != "" {
req.Header.Set("X-API-Key", r.apiKey)
}
if requestBody != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := r.client.Do(req)
if err != nil {
return wrapContractRequestError(r.errorPrefix, action, r.timeout, err)
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return buildContractStatusError(r.errorPrefix, action, resp)
}
if out == nil {
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
if err := decodeContractJSON(resp.Body, out); err != nil {
return fmt.Errorf("%s %s returned invalid JSON: %w", r.errorPrefix, action, err)
}
return nil
}
func (r contractRuntime) resolveURL(relativePath string, query url.Values) string {
resolved := *r.baseURL
resolved.RawQuery = ""
resolved.Fragment = ""
basePath := strings.TrimRight(strings.TrimSpace(resolved.Path), "/")
childPath := strings.TrimLeft(strings.TrimSpace(relativePath), "/")
switch {
case basePath == "" && childPath == "":
resolved.Path = ""
case basePath == "":
resolved.Path = "/" + childPath
case childPath == "":
resolved.Path = basePath
default:
resolved.Path = basePath + "/" + childPath
}
if len(query) > 0 {
resolved.RawQuery = query.Encode()
}
return resolved.String()
}
func doContractProbe(ctx context.Context, runtime contractRuntime, method string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, runtime.baseURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("%s probe request build failed: %w", runtime.errorPrefix, err)
}
if runtime.apiKey != "" {
req.Header.Set("X-API-Key", runtime.apiKey)
}
resp, err := runtime.client.Do(req)
if err != nil {
return nil, wrapContractRequestError(runtime.errorPrefix, "probe", runtime.timeout, err)
}
return resp, nil
}
func isReachableStatus(statusCode int) bool {
return (statusCode >= 200 && statusCode < 400) || statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden
}
func decodeContractJSON(body io.Reader, out any) error {
decoder := json.NewDecoder(body)
decoder.UseNumber()
if err := decoder.Decode(out); err != nil {
if errors.Is(err, io.EOF) {
return fmt.Errorf("empty response body")
}
return err
}
var extra json.RawMessage
if err := decoder.Decode(&extra); err != nil && !errors.Is(err, io.EOF) {
return err
}
return nil
}
func buildContractStatusError(errorPrefix string, action string, resp *http.Response) error {
body, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
if err != nil {
return fmt.Errorf("%s %s request failed: %s", errorPrefix, action, resp.Status)
}
message := strings.TrimSpace(string(body))
if message == "" {
return fmt.Errorf("%s %s request failed: %s", errorPrefix, action, resp.Status)
}
return fmt.Errorf("%s %s request failed: %s: %s", errorPrefix, action, resp.Status, message)
}
func wrapContractRequestError(errorPrefix string, action string, timeout time.Duration, err error) error {
if errors.Is(err, context.DeadlineExceeded) || isContractTimeoutError(err) {
return fmt.Errorf("%s %s request timed out after %s: %w", errorPrefix, action, timeout, err)
}
if errors.Is(err, context.Canceled) {
return fmt.Errorf("%s %s request canceled: %w", errorPrefix, action, err)
}
return fmt.Errorf("%s %s request failed: %w", errorPrefix, action, err)
}
func isContractTimeoutError(err error) bool {
var netErr net.Error
return errors.As(err, &netErr) && netErr.Timeout()
}

View File

@@ -5,7 +5,6 @@ import (
"fmt"
"net/http"
"net/url"
"strings"
"time"
"GoNavi-Wails/internal/connection"
@@ -18,33 +17,18 @@ func NewHTTPProvider() Provider { return &HTTPProvider{} }
func (p *HTTPProvider) Mode() string { return ModeEndpoint }
func (p *HTTPProvider) TestConnection(ctx context.Context, cfg connection.ConnectionConfig) error {
baseURL := strings.TrimSpace(cfg.JVM.Endpoint.BaseURL)
if baseURL == "" {
return fmt.Errorf("endpoint baseURL is required")
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("endpoint baseURL is invalid: %s", baseURL)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("endpoint scheme is unsupported: %s", parsed.Scheme)
runtime, err := newEndpointRuntime(cfg)
if err != nil {
return err
}
timeout := time.Duration(cfg.JVM.Endpoint.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = time.Duration(cfg.Timeout) * time.Second
}
if timeout <= 0 {
timeout = 5 * time.Second
}
client := &http.Client{Timeout: timeout}
resp, err := doEndpointProbe(ctx, client, baseURL, http.MethodHead)
resp, err := doContractProbe(ctx, runtime.contractRuntime, http.MethodHead)
if err != nil {
return err
}
if resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented {
_ = resp.Body.Close()
resp, err = doEndpointProbe(ctx, client, baseURL, http.MethodGet)
resp, err = doContractProbe(ctx, runtime.contractRuntime, http.MethodGet)
if err != nil {
return err
}
@@ -56,38 +40,111 @@ func (p *HTTPProvider) TestConnection(ctx context.Context, cfg connection.Connec
return fmt.Errorf("endpoint returned unexpected status: %d", resp.StatusCode)
}
func (p *HTTPProvider) ProbeCapabilities(ctx context.Context, cfg connection.ConnectionConfig) ([]Capability, error) {
return []Capability{{Mode: ModeEndpoint, CanBrowse: true, CanWrite: true, CanPreview: true, DisplayLabel: "Endpoint"}}, nil
func (p *HTTPProvider) ProbeCapabilities(_ context.Context, cfg connection.ConnectionConfig) ([]Capability, error) {
if _, err := newEndpointRuntime(cfg); err != nil {
return nil, err
}
readOnly := cfg.JVM.ReadOnly != nil && *cfg.JVM.ReadOnly
return []Capability{{
Mode: ModeEndpoint,
CanBrowse: true,
CanWrite: !readOnly,
CanPreview: true,
DisplayLabel: "Endpoint",
Reason: func() string {
if readOnly {
return "当前连接只读"
}
return ""
}(),
}}, nil
}
func (p *HTTPProvider) ListResources(ctx context.Context, cfg connection.ConnectionConfig, parentPath string) ([]ResourceSummary, error) {
return nil, errProviderNotImplemented(p.Mode(), "list resources")
runtime, err := newEndpointRuntime(cfg)
if err != nil {
return nil, err
}
query := url.Values{}
query.Set("parentPath", parentPath)
var resources []ResourceSummary
if err := runtime.doJSON(ctx, http.MethodGet, "list resources", "resources", query, nil, &resources); err != nil {
return nil, err
}
return resources, nil
}
func (p *HTTPProvider) GetValue(ctx context.Context, cfg connection.ConnectionConfig, resourcePath string) (ValueSnapshot, error) {
return ValueSnapshot{}, errProviderNotImplemented(p.Mode(), "get value")
runtime, err := newEndpointRuntime(cfg)
if err != nil {
return ValueSnapshot{}, err
}
query := url.Values{}
query.Set("resourcePath", resourcePath)
var snapshot ValueSnapshot
if err := runtime.doJSON(ctx, http.MethodGet, "get value", "value", query, nil, &snapshot); err != nil {
return ValueSnapshot{}, err
}
return snapshot, nil
}
func (p *HTTPProvider) PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) {
return ChangePreview{}, errProviderNotImplemented(p.Mode(), "preview change")
runtime, err := newEndpointRuntime(cfg)
if err != nil {
return ChangePreview{}, err
}
var preview ChangePreview
if err := runtime.doJSON(ctx, http.MethodPost, "preview change", "preview", nil, req, &preview); err != nil {
return ChangePreview{}, err
}
return preview, nil
}
func (p *HTTPProvider) ApplyChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ApplyResult, error) {
return ApplyResult{}, errProviderNotImplemented(p.Mode(), "apply change")
runtime, err := newEndpointRuntime(cfg)
if err != nil {
return ApplyResult{}, err
}
var result ApplyResult
if err := runtime.doJSON(ctx, http.MethodPost, "apply change", "apply", nil, req, &result); err != nil {
return ApplyResult{}, err
}
return result, nil
}
func doEndpointProbe(ctx context.Context, client *http.Client, baseURL string, method string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, baseURL, nil)
if err != nil {
return nil, fmt.Errorf("endpoint request build failed: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("endpoint request failed: %w", err)
}
return resp, nil
type endpointRuntime struct {
contractRuntime
}
func isReachableStatus(statusCode int) bool {
return (statusCode >= 200 && statusCode < 400) || statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden
func newEndpointRuntime(cfg connection.ConnectionConfig) (endpointRuntime, error) {
runtime, err := newContractRuntime(
cfg.JVM.Endpoint.BaseURL,
cfg.JVM.Endpoint.APIKey,
resolveEndpointTimeout(cfg),
"endpoint",
)
if err != nil {
return endpointRuntime{}, err
}
return endpointRuntime{
contractRuntime: runtime,
}, nil
}
func resolveEndpointTimeout(cfg connection.ConnectionConfig) time.Duration {
timeout := time.Duration(cfg.JVM.Endpoint.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = time.Duration(cfg.Timeout) * time.Second
}
if timeout <= 0 {
timeout = 5 * time.Second
}
return timeout
}

View File

@@ -0,0 +1,540 @@
package jvm
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"GoNavi-Wails/internal/connection"
)
func TestHTTPProviderListResourcesBuildsRequestAndDecodesResponse(t *testing.T) {
provider := NewHTTPProvider()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatalf("expected GET request, got %s", r.Method)
}
if r.URL.Path != "/manage/jvm/resources" {
t.Fatalf("expected path /manage/jvm/resources, got %s", r.URL.Path)
}
if got := r.URL.Query().Get("parentPath"); got != "/cache/orders" {
t.Fatalf("expected parentPath /cache/orders, got %q", got)
}
if got := r.Header.Get("X-API-Key"); got != "secret-token" {
t.Fatalf("expected X-API-Key header to pass through, got %q", got)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]ResourceSummary{{
ID: "cache.orders",
Kind: "folder",
Name: "Orders",
Path: "/cache/orders",
ProviderMode: ModeEndpoint,
CanRead: true,
CanWrite: true,
HasChildren: true,
}})
}))
defer server.Close()
items, err := provider.ListResources(context.Background(), newHTTPProviderTestConfig(server.URL+"/manage/jvm/", 3), "/cache/orders")
if err != nil {
t.Fatalf("ListResources returned error: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 resource, got %#v", items)
}
if items[0].ProviderMode != ModeEndpoint || items[0].Path != "/cache/orders" {
t.Fatalf("unexpected resource payload: %#v", items[0])
}
}
func TestHTTPProviderGetValueDecodesResponse(t *testing.T) {
provider := NewHTTPProvider()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatalf("expected GET request, got %s", r.Method)
}
if r.URL.Path != "/runtime/value" {
t.Fatalf("expected path /runtime/value, got %s", r.URL.Path)
}
if got := r.URL.Query().Get("resourcePath"); got != "/cache/orders" {
t.Fatalf("expected resourcePath /cache/orders, got %q", got)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(ValueSnapshot{
ResourceID: "/cache/orders",
Kind: "entry",
Format: "json",
Version: "v1",
Value: map[string]any{
"status": "ready",
},
})
}))
defer server.Close()
value, err := provider.GetValue(context.Background(), newHTTPProviderTestConfig(server.URL+"/runtime", 3), "/cache/orders")
if err != nil {
t.Fatalf("GetValue returned error: %v", err)
}
if value.ResourceID != "/cache/orders" || value.Version != "v1" {
t.Fatalf("unexpected value payload: %#v", value)
}
}
func TestHTTPProviderPreviewChangeAndApplySendJSONBody(t *testing.T) {
provider := NewHTTPProvider()
request := ChangeRequest{
ProviderMode: ModeEndpoint,
ResourceID: "/cache/orders",
Action: "put",
Reason: "refresh cache",
ExpectedVersion: "v1",
Payload: map[string]any{
"status": "warm",
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("failed to read request body: %v", err)
}
defer r.Body.Close()
if contentType := r.Header.Get("Content-Type"); !strings.Contains(contentType, "application/json") {
t.Fatalf("expected JSON content type, got %q", contentType)
}
var got ChangeRequest
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
if got.ResourceID != request.ResourceID || got.Action != request.Action || got.ExpectedVersion != request.ExpectedVersion {
t.Fatalf("unexpected request body: %#v", got)
}
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/manage/jvm/preview":
if r.Method != http.MethodPost {
t.Fatalf("expected POST /preview, got %s", r.Method)
}
_ = json.NewEncoder(w).Encode(ChangePreview{
Allowed: true,
Summary: "preview ready",
RiskLevel: "low",
Before: ValueSnapshot{
ResourceID: request.ResourceID,
Kind: "entry",
Format: "json",
},
After: ValueSnapshot{
ResourceID: request.ResourceID,
Kind: "entry",
Format: "json",
Value: map[string]any{
"status": "warm",
},
},
})
case "/manage/jvm/apply":
if r.Method != http.MethodPost {
t.Fatalf("expected POST /apply, got %s", r.Method)
}
_ = json.NewEncoder(w).Encode(ApplyResult{
Status: "applied",
Message: "updated",
UpdatedValue: ValueSnapshot{
ResourceID: request.ResourceID,
Kind: "entry",
Format: "json",
Value: map[string]any{
"status": "warm",
},
},
})
default:
t.Fatalf("unexpected request path: %s", r.URL.Path)
}
}))
defer server.Close()
preview, err := provider.PreviewChange(context.Background(), newHTTPProviderTestConfig(server.URL+"/manage/jvm", 3), request)
if err != nil {
t.Fatalf("PreviewChange returned error: %v", err)
}
if !preview.Allowed || preview.Summary != "preview ready" {
t.Fatalf("unexpected preview payload: %#v", preview)
}
result, err := provider.ApplyChange(context.Background(), newHTTPProviderTestConfig(server.URL+"/manage/jvm", 3), request)
if err != nil {
t.Fatalf("ApplyChange returned error: %v", err)
}
if result.Status != "applied" || result.UpdatedValue.ResourceID != request.ResourceID {
t.Fatalf("unexpected apply payload: %#v", result)
}
}
func TestHTTPProviderProbeCapabilitiesReflectsReadOnlyConnection(t *testing.T) {
provider := NewHTTPProvider()
cfg := newHTTPProviderTestConfig("https://orders.internal/manage/jvm", 3)
readOnly := true
cfg.JVM.ReadOnly = &readOnly
caps, err := provider.ProbeCapabilities(context.Background(), cfg)
if err != nil {
t.Fatalf("ProbeCapabilities returned error: %v", err)
}
if len(caps) != 1 {
t.Fatalf("expected one capability, got %#v", caps)
}
if caps[0].CanWrite {
t.Fatalf("expected endpoint capability to be readonly, got %#v", caps[0])
}
if caps[0].Reason != "当前连接只读" {
t.Fatalf("expected readonly reason, got %#v", caps[0])
}
}
func TestHTTPProviderProbeCapabilitiesReturnsConfigValidationError(t *testing.T) {
provider := NewHTTPProvider()
_, err := provider.ProbeCapabilities(context.Background(), connection.ConnectionConfig{
Type: "jvm",
JVM: connection.JVMConfig{
Endpoint: connection.JVMEndpointConfig{
BaseURL: "",
},
},
})
if err == nil {
t.Fatal("expected endpoint config validation error")
}
if !strings.Contains(err.Error(), "endpoint baseURL is required") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestHTTPProviderReturnsReadableStatusErrors(t *testing.T) {
provider := NewHTTPProvider()
tests := []struct {
name string
path string
code int
body string
call func(context.Context, Provider, connection.ConnectionConfig) error
want []string
}{
{
name: "list resources unauthorized",
path: "/resources",
code: http.StatusUnauthorized,
body: "missing api key",
call: func(ctx context.Context, provider Provider, cfg connection.ConnectionConfig) error {
_, err := provider.ListResources(ctx, cfg, "/cache/orders")
return err
},
want: []string{"list resources", "401 Unauthorized", "missing api key"},
},
{
name: "get value forbidden",
path: "/value",
code: http.StatusForbidden,
body: "access denied",
call: func(ctx context.Context, provider Provider, cfg connection.ConnectionConfig) error {
_, err := provider.GetValue(ctx, cfg, "/cache/orders")
return err
},
want: []string{"get value", "403 Forbidden", "access denied"},
},
{
name: "preview change server error",
path: "/preview",
code: http.StatusInternalServerError,
body: "preview backend exploded",
call: func(ctx context.Context, provider Provider, cfg connection.ConnectionConfig) error {
_, err := provider.PreviewChange(ctx, cfg, ChangeRequest{
ProviderMode: ModeEndpoint,
ResourceID: "/cache/orders",
Action: "put",
Reason: "refresh cache",
})
return err
},
want: []string{"preview change", "500 Internal Server Error", "preview backend exploded"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/manage/jvm"+tc.path {
t.Fatalf("expected path %s, got %s", "/manage/jvm"+tc.path, r.URL.Path)
}
http.Error(w, tc.body, tc.code)
}))
defer server.Close()
err := tc.call(context.Background(), provider, newHTTPProviderTestConfig(server.URL+"/manage/jvm", 3))
if err == nil {
t.Fatal("expected request error")
}
for _, fragment := range tc.want {
if !strings.Contains(err.Error(), fragment) {
t.Fatalf("expected error %q to contain %q", err.Error(), fragment)
}
}
})
}
}
func TestHTTPProviderReturnsInvalidJSONError(t *testing.T) {
provider := NewHTTPProvider()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"resourceId":`))
}))
defer server.Close()
_, err := provider.GetValue(context.Background(), newHTTPProviderTestConfig(server.URL, 3), "/cache/orders")
if err == nil {
t.Fatal("expected invalid JSON error")
}
if !strings.Contains(strings.ToLower(err.Error()), "invalid json") {
t.Fatalf("expected invalid JSON error, got %v", err)
}
}
func TestHTTPProviderReturnsTimeoutError(t *testing.T) {
provider := NewHTTPProvider()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(1200 * time.Millisecond)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]ResourceSummary{})
}))
defer server.Close()
_, err := provider.ListResources(context.Background(), newHTTPProviderTestConfig(server.URL, 1), "/cache/orders")
if err == nil {
t.Fatal("expected timeout error")
}
if !strings.Contains(err.Error(), "timed out after 1s") {
t.Fatalf("expected timeout error, got %v", err)
}
}
func TestHTTPProviderRealEndpointRoundTrip(t *testing.T) {
if _, err := exec.LookPath("java"); err != nil {
t.Skipf("java 不可用,跳过真实 Endpoint 集成测试: %v", err)
}
if _, err := exec.LookPath("javac"); err != nil {
t.Skipf("javac 不可用,跳过真实 Endpoint 集成测试: %v", err)
}
provider := NewHTTPProvider()
fixture := startEndpointFixture(t)
cfg := newHTTPProviderTestConfig(fixture.baseURL+"/manage/jvm", 5)
waitForTest(t, 10*time.Second, func() error {
return provider.TestConnection(context.Background(), cfg)
})
caps, err := provider.ProbeCapabilities(context.Background(), cfg)
if err != nil {
t.Fatalf("ProbeCapabilities returned error: %v", err)
}
if len(caps) != 1 || !caps[0].CanBrowse || !caps[0].CanWrite || !caps[0].CanPreview {
t.Fatalf("unexpected capabilities: %#v", caps)
}
rootItems, err := provider.ListResources(context.Background(), cfg, "")
if err != nil {
t.Fatalf("ListResources(root) returned error: %v", err)
}
if len(rootItems) != 1 || rootItems[0].Name != "Orders" || !rootItems[0].HasChildren {
t.Fatalf("unexpected root resources: %#v", rootItems)
}
children, err := provider.ListResources(context.Background(), cfg, rootItems[0].Path)
if err != nil {
t.Fatalf("ListResources(child) returned error: %v", err)
}
stateResource := findResourceByName(t, children, "State")
before, err := provider.GetValue(context.Background(), cfg, stateResource.Path)
if err != nil {
t.Fatalf("GetValue(before) returned error: %v", err)
}
beforeMap, ok := before.Value.(map[string]any)
if !ok || beforeMap["status"] != "warm" || strings.TrimSpace(before.Version) == "" {
t.Fatalf("unexpected initial value snapshot: %#v", before)
}
preview, err := provider.PreviewChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeEndpoint,
ResourceID: stateResource.Path,
Action: "put",
Reason: "更新订单缓存状态",
ExpectedVersion: before.Version,
Payload: map[string]any{
"status": "hot",
"lastUpdated": "preview-check",
},
})
if err != nil {
t.Fatalf("PreviewChange returned error: %v", err)
}
previewAfter, ok := preview.After.Value.(map[string]any)
if !preview.Allowed || !ok || previewAfter["status"] != "hot" {
t.Fatalf("unexpected preview payload: %#v", preview)
}
result, err := provider.ApplyChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeEndpoint,
ResourceID: stateResource.Path,
Action: "put",
Reason: "更新订单缓存状态",
ExpectedVersion: before.Version,
Payload: map[string]any{
"status": "hot",
"lastUpdated": "manual-check",
},
})
if err != nil {
t.Fatalf("ApplyChange returned error: %v", err)
}
updatedMap, ok := result.UpdatedValue.Value.(map[string]any)
if result.Status != "applied" || !ok || updatedMap["status"] != "hot" || updatedMap["lastUpdated"] != "manual-check" {
t.Fatalf("unexpected apply result: %#v", result)
}
after, err := provider.GetValue(context.Background(), cfg, stateResource.Path)
if err != nil {
t.Fatalf("GetValue(after) returned error: %v", err)
}
afterMap, ok := after.Value.(map[string]any)
if !ok || afterMap["status"] != "hot" || after.Version == before.Version {
t.Fatalf("unexpected updated value snapshot: %#v", after)
}
}
type endpointFixtureProcess struct {
port int
baseURL string
cmd *exec.Cmd
}
func startEndpointFixture(t *testing.T) endpointFixtureProcess {
t.Helper()
javaBin, err := exec.LookPath("java")
if err != nil {
t.Fatalf("look up java failed: %v", err)
}
javacBin, err := exec.LookPath("javac")
if err != nil {
t.Fatalf("look up javac failed: %v", err)
}
classesDir := filepath.Join(t.TempDir(), "endpoint-fixture-classes")
sourceRoot := filepath.Join(testRepoRoot(t), "internal", "jvm", "testdata", "endpointfixture", "src")
javaFiles, err := filepath.Glob(filepath.Join(sourceRoot, "com", "gonavi", "fixture", "*.java"))
if err != nil {
t.Fatalf("glob endpoint fixture sources failed: %v", err)
}
if len(javaFiles) == 0 {
t.Fatalf("expected endpoint fixture java files under %s", sourceRoot)
}
compileCmd := exec.Command(javacBin, append([]string{"-d", classesDir}, javaFiles...)...)
output, err := compileCmd.CombinedOutput()
if err != nil {
t.Fatalf("compile endpoint fixture failed: %v\n%s", err, strings.TrimSpace(string(output)))
}
port := reserveTCPPort(t)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
cmd := exec.CommandContext(ctx, javaBin, "-cp", classesDir, "com.gonavi.fixture.EndpointTestServer", fmt.Sprintf("%d", port))
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("endpoint fixture stdout pipe failed: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("start endpoint fixture failed: %v", err)
}
t.Cleanup(func() {
cancel()
_ = cmd.Wait()
})
ready := make(chan error, 1)
go func() {
line, readErr := bufio.NewReader(stdout).ReadString('\n')
if readErr != nil {
ready <- fmt.Errorf("endpoint fixture readiness read failed: %w", readErr)
return
}
if strings.TrimSpace(line) != "READY" {
ready <- fmt.Errorf("unexpected endpoint fixture readiness line: %q", strings.TrimSpace(line))
return
}
ready <- nil
}()
select {
case err := <-ready:
if err != nil {
t.Fatalf("wait endpoint fixture ready failed: %v", err)
}
case <-time.After(20 * time.Second):
t.Fatal("endpoint fixture did not become ready within 20s")
}
waitForTest(t, 10*time.Second, func() error {
conn, dialErr := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 500*time.Millisecond)
if dialErr != nil {
return dialErr
}
_ = conn.Close()
return nil
})
return endpointFixtureProcess{
port: port,
baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
cmd: cmd,
}
}
func newHTTPProviderTestConfig(baseURL string, timeoutSeconds int) connection.ConnectionConfig {
readOnly := false
return connection.ConnectionConfig{
Type: "jvm",
Timeout: timeoutSeconds,
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
AllowedModes: []string{ModeEndpoint},
PreferredMode: ModeEndpoint,
Endpoint: connection.JVMEndpointConfig{
BaseURL: baseURL,
APIKey: "secret-token",
TimeoutSeconds: timeoutSeconds,
},
},
}
}

756
internal/jvm/jmx_helper.go Normal file
View File

@@ -0,0 +1,756 @@
package jvm
import (
"bytes"
"context"
"crypto/sha256"
_ "embed"
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"GoNavi-Wails/internal/connection"
)
const (
jmxResourceScheme = "jmx"
jmxResourceKindRoot = "root"
jmxResourceKindDomain = "domain"
jmxResourceKindMBean = "mbean"
jmxResourceKindAttribute = "attribute"
jmxResourceKindOperation = "operation"
jmxHelperCommandPing = "ping"
jmxHelperCommandList = "list"
jmxHelperCommandGet = "get"
jmxHelperCommandPreview = "preview"
jmxHelperCommandApply = "apply"
jmxHelperMainClass = "com.gonavi.jmxhelper.JmxHelperMain"
)
var (
jmxHelperCompileMu sync.Mutex
jmxHelperCommandContext = exec.CommandContext
jmxHelperLookPath = exec.LookPath
)
//go:embed jmxhelper_assets/jmx-helper-runtime.jar
var embeddedJMXHelperJar []byte
type jmxResourceTarget struct {
Kind string
Domain string
ObjectName string
Attribute string
Operation string
Signature []string
}
type jmxHelperRuntime struct {
javaBinary string
classpath string
}
type jmxHelperRequest struct {
Command string `json:"command"`
Connection jmxHelperConnection `json:"connection"`
Target *jmxHelperTarget `json:"target,omitempty"`
Change *jmxHelperChangePlan `json:"change,omitempty"`
}
type jmxHelperConnection struct {
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
DomainAllowlist []string `json:"domainAllowlist,omitempty"`
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
}
type jmxHelperTarget struct {
Kind string `json:"kind"`
Domain string `json:"domain,omitempty"`
ObjectName string `json:"objectName,omitempty"`
Attribute string `json:"attribute,omitempty"`
Operation string `json:"operation,omitempty"`
Signature []string `json:"signature,omitempty"`
}
type jmxHelperChangePlan struct {
Action string `json:"action,omitempty"`
Reason string `json:"reason,omitempty"`
ExpectedVersion string `json:"expectedVersion,omitempty"`
Payload map[string]any `json:"payload,omitempty"`
}
type jmxHelperResponse struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Details map[string]any `json:"details,omitempty"`
Resources []jmxHelperResource `json:"resources,omitempty"`
Snapshot *jmxHelperSnapshot `json:"snapshot,omitempty"`
Preview *jmxHelperPreview `json:"preview,omitempty"`
ApplyResult *jmxHelperApplyResponse `json:"applyResult,omitempty"`
}
type jmxHelperResource struct {
Kind string `json:"kind"`
Domain string `json:"domain,omitempty"`
ObjectName string `json:"objectName,omitempty"`
Attribute string `json:"attribute,omitempty"`
Operation string `json:"operation,omitempty"`
Signature []string `json:"signature,omitempty"`
Name string `json:"name"`
CanRead bool `json:"canRead"`
CanWrite bool `json:"canWrite"`
HasChildren bool `json:"hasChildren"`
Sensitive bool `json:"sensitive,omitempty"`
}
type jmxHelperSnapshot struct {
Kind string `json:"kind"`
Format string `json:"format"`
Value any `json:"value"`
Description string `json:"description,omitempty"`
Sensitive bool `json:"sensitive,omitempty"`
SupportedActions []ActionDefinition `json:"supportedActions,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type jmxHelperPreview struct {
Allowed bool `json:"allowed"`
RequiresConfirmation bool `json:"requiresConfirmation,omitempty"`
Summary string `json:"summary"`
RiskLevel string `json:"riskLevel"`
BlockingReason string `json:"blockingReason,omitempty"`
Before *jmxHelperSnapshot `json:"before,omitempty"`
After *jmxHelperSnapshot `json:"after,omitempty"`
}
type jmxHelperApplyResponse struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
UpdatedValue *jmxHelperSnapshot `json:"updatedValue,omitempty"`
}
func resolveJMXHost(cfg connection.ConnectionConfig) string {
host := strings.TrimSpace(cfg.JVM.JMX.Host)
if host == "" {
host = strings.TrimSpace(cfg.Host)
}
return host
}
func resolveJMXPort(cfg connection.ConnectionConfig) int {
if cfg.JVM.JMX.Port != 0 {
return cfg.JVM.JMX.Port
}
if cfg.Port > 0 {
return cfg.Port
}
return defaultJMXPort
}
func resolveJMXTimeout(cfg connection.ConnectionConfig) time.Duration {
timeout := time.Duration(cfg.Timeout) * time.Second
if timeout <= 0 {
timeout = 5 * time.Second
}
return timeout
}
func normalizeJMXAllowlist(values []string) []string {
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, item := range values {
trimmed := strings.TrimSpace(item)
if trimmed == "" {
continue
}
if _, exists := seen[trimmed]; exists {
continue
}
seen[trimmed] = struct{}{}
result = append(result, trimmed)
}
sort.Strings(result)
return result
}
func validateJMXConnection(cfg connection.ConnectionConfig) error {
host := resolveJMXHost(cfg)
if host == "" {
return fmt.Errorf("jmx host is required")
}
port := resolveJMXPort(cfg)
if port <= 0 {
return fmt.Errorf("jmx port is invalid: %d", port)
}
return nil
}
func buildJMXResourcePath(target jmxResourceTarget) string {
query := url.Values{}
var path string
switch target.Kind {
case jmxResourceKindDomain:
path = "/domain/" + url.PathEscape(target.Domain)
case jmxResourceKindMBean:
path = "/mbean/" + url.PathEscape(target.ObjectName)
case jmxResourceKindAttribute:
path = "/attribute/" + url.PathEscape(target.ObjectName) + "/" + url.PathEscape(target.Attribute)
case jmxResourceKindOperation:
path = "/operation/" + url.PathEscape(target.ObjectName) + "/" + url.PathEscape(target.Operation)
if len(target.Signature) > 0 {
query.Set("signature", strings.Join(target.Signature, ","))
}
default:
return ""
}
if len(query) == 0 {
return jmxResourceScheme + ":" + path
}
return jmxResourceScheme + ":" + path + "?" + query.Encode()
}
func parseJMXResourcePath(raw string) (jmxResourceTarget, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return jmxResourceTarget{}, fmt.Errorf("resource path is empty")
}
parsed, err := url.Parse(trimmed)
if err != nil {
return jmxResourceTarget{}, fmt.Errorf("resource path parse failed: %w", err)
}
if !strings.EqualFold(parsed.Scheme, jmxResourceScheme) {
return jmxResourceTarget{}, fmt.Errorf("resource path scheme must be %q", jmxResourceScheme)
}
segments := strings.Split(strings.TrimPrefix(parsed.EscapedPath(), "/"), "/")
if len(segments) == 0 || segments[0] == "" {
return jmxResourceTarget{}, fmt.Errorf("resource path kind is missing")
}
unescape := func(value string) (string, error) {
decoded, decodeErr := url.PathUnescape(value)
if decodeErr != nil {
return "", fmt.Errorf("resource path decode failed: %w", decodeErr)
}
return decoded, nil
}
target := jmxResourceTarget{Kind: segments[0]}
switch target.Kind {
case jmxResourceKindDomain:
if len(segments) != 2 {
return jmxResourceTarget{}, fmt.Errorf("domain resource path must contain exactly 2 segments")
}
target.Domain, err = unescape(segments[1])
case jmxResourceKindMBean:
if len(segments) != 2 {
return jmxResourceTarget{}, fmt.Errorf("mbean resource path must contain exactly 2 segments")
}
target.ObjectName, err = unescape(segments[1])
case jmxResourceKindAttribute:
if len(segments) != 3 {
return jmxResourceTarget{}, fmt.Errorf("attribute resource path must contain exactly 3 segments")
}
target.ObjectName, err = unescape(segments[1])
if err == nil {
target.Attribute, err = unescape(segments[2])
}
case jmxResourceKindOperation:
if len(segments) != 3 {
return jmxResourceTarget{}, fmt.Errorf("operation resource path must contain exactly 3 segments")
}
target.ObjectName, err = unescape(segments[1])
if err == nil {
target.Operation, err = unescape(segments[2])
}
if signatureValue := strings.TrimSpace(parsed.Query().Get("signature")); signatureValue != "" {
target.Signature = splitSignature(signatureValue)
}
default:
return jmxResourceTarget{}, fmt.Errorf("resource path kind %q is unsupported", target.Kind)
}
if err != nil {
return jmxResourceTarget{}, err
}
return target, nil
}
func splitSignature(raw string) []string {
parts := strings.Split(raw, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
result = append(result, trimmed)
}
return result
}
func helperTargetFromResource(target jmxResourceTarget) *jmxHelperTarget {
return &jmxHelperTarget{
Kind: target.Kind,
Domain: target.Domain,
ObjectName: target.ObjectName,
Attribute: target.Attribute,
Operation: target.Operation,
Signature: append([]string(nil), target.Signature...),
}
}
func resourceTargetFromHelper(item jmxHelperResource) jmxResourceTarget {
return jmxResourceTarget{
Kind: item.Kind,
Domain: item.Domain,
ObjectName: item.ObjectName,
Attribute: item.Attribute,
Operation: item.Operation,
Signature: append([]string(nil), item.Signature...),
}
}
func parentResourcePath(target jmxResourceTarget) string {
switch target.Kind {
case jmxResourceKindDomain:
return ""
case jmxResourceKindMBean:
return buildJMXResourcePath(jmxResourceTarget{Kind: jmxResourceKindDomain, Domain: domainFromObjectName(target.ObjectName)})
case jmxResourceKindAttribute, jmxResourceKindOperation:
return buildJMXResourcePath(jmxResourceTarget{Kind: jmxResourceKindMBean, ObjectName: target.ObjectName})
default:
return ""
}
}
func domainFromObjectName(objectName string) string {
if idx := strings.Index(strings.TrimSpace(objectName), ":"); idx > 0 {
return objectName[:idx]
}
return ""
}
func helperContextSummary(cfg connection.ConnectionConfig, target *jmxResourceTarget) string {
base := fmt.Sprintf("%s:%d", resolveJMXHost(cfg), resolveJMXPort(cfg))
if target == nil {
return base
}
switch target.Kind {
case jmxResourceKindDomain:
return fmt.Sprintf("%s domain=%s", base, target.Domain)
case jmxResourceKindMBean:
return fmt.Sprintf("%s mbean=%s", base, target.ObjectName)
case jmxResourceKindAttribute:
return fmt.Sprintf("%s attribute=%s::%s", base, target.ObjectName, target.Attribute)
case jmxResourceKindOperation:
return fmt.Sprintf("%s operation=%s::%s(%s)", base, target.ObjectName, target.Operation, strings.Join(target.Signature, ","))
default:
return base
}
}
func runJMXHelper(
ctx context.Context,
cfg connection.ConnectionConfig,
command string,
target *jmxResourceTarget,
change *ChangeRequest,
) (jmxHelperResponse, error) {
if err := validateJMXConnection(cfg); err != nil {
return jmxHelperResponse{}, err
}
runtimeInfo, err := ensureJMXHelperRuntime(ctx)
if err != nil {
return jmxHelperResponse{}, err
}
requestPayload := jmxHelperRequest{
Command: command,
Connection: jmxHelperConnection{
Host: resolveJMXHost(cfg),
Port: resolveJMXPort(cfg),
Username: strings.TrimSpace(cfg.JVM.JMX.Username),
Password: cfg.JVM.JMX.Password,
DomainAllowlist: normalizeJMXAllowlist(cfg.JVM.JMX.DomainAllowlist),
TimeoutSeconds: int(resolveJMXTimeout(cfg).Seconds()),
},
}
if target != nil {
requestPayload.Target = helperTargetFromResource(*target)
}
if change != nil {
requestPayload.Change = &jmxHelperChangePlan{
Action: strings.TrimSpace(change.Action),
Reason: strings.TrimSpace(change.Reason),
ExpectedVersion: strings.TrimSpace(change.ExpectedVersion),
Payload: change.Payload,
}
}
input, err := json.Marshal(requestPayload)
if err != nil {
return jmxHelperResponse{}, fmt.Errorf("encode JMX helper request failed: %w", err)
}
execCtx, cancel := withJMXTimeout(ctx, cfg)
defer cancel()
cmd := jmxHelperCommandContext(execCtx, runtimeInfo.javaBinary, "-cp", runtimeInfo.classpath, jmxHelperMainClass)
cmd.Stdin = bytes.NewReader(input)
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
stderrText := strings.TrimSpace(stderr.String())
if stderrText == "" {
stderrText = "<empty>"
}
return jmxHelperResponse{}, fmt.Errorf(
"jmx helper %s failed for %s: %w; stderr: %s",
command,
helperContextSummary(cfg, target),
err,
stderrText,
)
}
var response jmxHelperResponse
if err := json.Unmarshal(stdout.Bytes(), &response); err != nil {
return jmxHelperResponse{}, fmt.Errorf(
"decode JMX helper %s response failed for %s: %w; stdout: %s",
command,
helperContextSummary(cfg, target),
err,
strings.TrimSpace(stdout.String()),
)
}
if !response.OK {
errText := strings.TrimSpace(response.Error)
if errText == "" {
errText = "unknown helper failure"
}
if len(response.Details) > 0 {
detailsJSON, marshalErr := json.Marshal(response.Details)
if marshalErr == nil {
errText += "; details=" + string(detailsJSON)
}
}
return jmxHelperResponse{}, fmt.Errorf("jmx helper %s failed for %s: %s", command, helperContextSummary(cfg, target), errText)
}
return response, nil
}
func withJMXTimeout(ctx context.Context, cfg connection.ConnectionConfig) (context.Context, context.CancelFunc) {
if _, hasDeadline := ctx.Deadline(); hasDeadline {
return ctx, func() {}
}
return context.WithTimeout(ctx, resolveJMXTimeout(cfg))
}
func ensureJMXHelperRuntime(ctx context.Context) (jmxHelperRuntime, error) {
if err := ctx.Err(); err != nil {
return jmxHelperRuntime{}, err
}
javaBinary, err := resolveJMXBinary("GONAVI_JMX_JAVA_BIN", "java")
if err != nil {
return jmxHelperRuntime{}, err
}
if overridden := strings.TrimSpace(os.Getenv("GONAVI_JMX_HELPER_CLASSPATH")); overridden != "" {
return jmxHelperRuntime{javaBinary: javaBinary, classpath: overridden}, nil
}
jarBytes, fingerprint, err := resolveEmbeddedJMXHelperJar()
if err != nil {
return jmxHelperRuntime{}, err
}
cacheRoot, err := resolveJMXHelperCacheRoot()
if err != nil {
return jmxHelperRuntime{}, err
}
jarPath := filepath.Join(cacheRoot, fingerprint, "jmx-helper-runtime.jar")
if _, statErr := os.Stat(jarPath); statErr == nil {
return jmxHelperRuntime{javaBinary: javaBinary, classpath: jarPath}, nil
}
jmxHelperCompileMu.Lock()
defer jmxHelperCompileMu.Unlock()
if err := ctx.Err(); err != nil {
return jmxHelperRuntime{}, err
}
if _, statErr := os.Stat(jarPath); statErr == nil {
return jmxHelperRuntime{javaBinary: javaBinary, classpath: jarPath}, nil
}
if err := os.MkdirAll(filepath.Dir(jarPath), 0o755); err != nil {
return jmxHelperRuntime{}, fmt.Errorf("create JMX helper cache parent failed: %w", err)
}
tmpPath := jarPath + ".tmp"
_ = os.Remove(tmpPath)
defer func() {
_ = os.Remove(tmpPath)
}()
if err := os.WriteFile(tmpPath, jarBytes, 0o644); err != nil {
return jmxHelperRuntime{}, fmt.Errorf("write embedded JMX helper jar failed: %w", err)
}
_ = os.Remove(jarPath)
if err := os.Rename(tmpPath, jarPath); err != nil {
return jmxHelperRuntime{}, fmt.Errorf("publish embedded JMX helper jar failed: %w", err)
}
return jmxHelperRuntime{javaBinary: javaBinary, classpath: jarPath}, nil
}
func resolveJMXBinary(envKey string, defaultName string) (string, error) {
if overridden := strings.TrimSpace(os.Getenv(envKey)); overridden != "" {
return overridden, nil
}
bin, err := jmxHelperLookPath(defaultName)
if err != nil {
return "", fmt.Errorf("required JMX helper dependency %q not found: %w", defaultName, err)
}
return bin, nil
}
func resolveEmbeddedJMXHelperJar() ([]byte, string, error) {
if len(embeddedJMXHelperJar) == 0 {
return nil, "", fmt.Errorf("embedded JMX helper jar is empty")
}
sum := sha256.Sum256(embeddedJMXHelperJar)
return embeddedJMXHelperJar, hex.EncodeToString(sum[:]), nil
}
func resolveJMXHelperCacheRoot() (string, error) {
if overridden := strings.TrimSpace(os.Getenv("GONAVI_JMX_HELPER_CACHE_DIR")); overridden != "" {
return overridden, nil
}
cacheDir, err := os.UserCacheDir()
if err != nil {
return "", fmt.Errorf("resolve JMX helper cache dir failed: %w", err)
}
return filepath.Join(cacheDir, "gonavi", "jmx-helper"), nil
}
func inferSnapshotFormat(value any) string {
switch value.(type) {
case nil:
return "null"
case string:
return "string"
case bool:
return "boolean"
case float64, float32, int, int64, int32, int16, int8, uint, uint64, uint32, uint16, uint8, json.Number:
return "number"
case []any:
return "array"
default:
return "json"
}
}
func computeSnapshotVersion(snapshot ValueSnapshot) string {
payload := map[string]any{
"kind": strings.TrimSpace(snapshot.Kind),
"format": strings.TrimSpace(snapshot.Format),
"value": snapshot.Value,
"metadata": snapshot.Metadata,
}
encoded, err := json.Marshal(payload)
if err != nil {
encoded = []byte(fmt.Sprintf("%#v", payload))
}
sum := sha256.Sum256(encoded)
return hex.EncodeToString(sum[:])
}
func valueSnapshotFromHelper(target jmxResourceTarget, snapshot *jmxHelperSnapshot) (ValueSnapshot, error) {
if snapshot == nil {
return ValueSnapshot{}, fmt.Errorf("helper did not return snapshot for %s", buildJMXResourcePath(target))
}
normalized := ValueSnapshot{
ResourceID: buildJMXResourcePath(target),
Kind: strings.TrimSpace(snapshot.Kind),
Format: strings.TrimSpace(snapshot.Format),
Value: snapshot.Value,
Description: strings.TrimSpace(snapshot.Description),
Sensitive: snapshot.Sensitive,
SupportedActions: cloneActionDefinitions(snapshot.SupportedActions),
Metadata: cloneStringAnyMap(snapshot.Metadata),
}
if normalized.Kind == "" {
normalized.Kind = target.Kind
}
if normalized.Format == "" {
normalized.Format = inferSnapshotFormat(normalized.Value)
}
normalized.Version = computeSnapshotVersion(normalized)
return normalized, nil
}
func previewFromHelper(target jmxResourceTarget, preview *jmxHelperPreview) (ChangePreview, error) {
if preview == nil {
return ChangePreview{}, fmt.Errorf("helper did not return preview for %s", buildJMXResourcePath(target))
}
result := ChangePreview{
Allowed: preview.Allowed,
RequiresConfirmation: preview.RequiresConfirmation,
Summary: strings.TrimSpace(preview.Summary),
RiskLevel: strings.TrimSpace(preview.RiskLevel),
BlockingReason: strings.TrimSpace(preview.BlockingReason),
}
if result.Summary == "" {
result.Summary = buildJMXResourcePath(target)
}
if result.RiskLevel == "" {
result.RiskLevel = "medium"
}
if preview.Before != nil {
before, err := valueSnapshotFromHelper(target, preview.Before)
if err != nil {
return ChangePreview{}, err
}
result.Before = before
}
if preview.After != nil {
after, err := valueSnapshotFromHelper(target, preview.After)
if err != nil {
return ChangePreview{}, err
}
result.After = after
}
return result, nil
}
func applyResultFromHelper(target jmxResourceTarget, result *jmxHelperApplyResponse) (ApplyResult, error) {
if result == nil {
return ApplyResult{}, fmt.Errorf("helper did not return apply result for %s", buildJMXResourcePath(target))
}
updatedValue, err := valueSnapshotFromHelper(target, result.UpdatedValue)
if err != nil {
return ApplyResult{}, err
}
return ApplyResult{
Status: strings.TrimSpace(result.Status),
Message: strings.TrimSpace(result.Message),
UpdatedValue: updatedValue,
}, nil
}
func cloneStringAnyMap(input map[string]any) map[string]any {
if len(input) == 0 {
return nil
}
result := make(map[string]any, len(input))
for key, value := range input {
result[key] = value
}
return result
}
func cloneActionDefinitions(input []ActionDefinition) []ActionDefinition {
if len(input) == 0 {
return nil
}
result := make([]ActionDefinition, 0, len(input))
for _, item := range input {
copied := item
if len(item.PayloadFields) > 0 {
copied.PayloadFields = append([]ActionPayloadField(nil), item.PayloadFields...)
}
if item.PayloadExample != nil {
copied.PayloadExample = cloneStringAnyMap(item.PayloadExample)
}
result = append(result, copied)
}
return result
}
func resourceSummaryFromHelper(item jmxHelperResource) ResourceSummary {
target := resourceTargetFromHelper(item)
path := buildJMXResourcePath(target)
return ResourceSummary{
ID: path,
ParentID: parentResourcePath(target),
Kind: item.Kind,
Name: item.Name,
Path: path,
ProviderMode: ModeJMX,
CanRead: item.CanRead,
CanWrite: item.CanWrite,
HasChildren: item.HasChildren,
Sensitive: item.Sensitive,
}
}
func staleVersionError(resourcePath string, expected string, actual string) error {
return fmt.Errorf(
"jmx apply change rejected for %s: version mismatch, expected %s, got %s",
resourcePath,
strings.TrimSpace(expected),
strings.TrimSpace(actual),
)
}
func parseParentResourcePath(raw string) (*jmxResourceTarget, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil, nil
}
target, err := parseJMXResourcePath(trimmed)
if err != nil {
return nil, fmt.Errorf("invalid JMX parent resource path %q: %w", raw, err)
}
if target.Kind != jmxResourceKindDomain && target.Kind != jmxResourceKindMBean {
return nil, fmt.Errorf("JMX parent resource path %q cannot be listed", raw)
}
return &target, nil
}
func parseRequiredResourcePath(raw string) (jmxResourceTarget, error) {
target, err := parseJMXResourcePath(raw)
if err != nil {
return jmxResourceTarget{}, fmt.Errorf("invalid JMX resource path %q: %w", raw, err)
}
return target, nil
}
func normalizeHelperPort(value any) int {
switch typed := value.(type) {
case float64:
return int(typed)
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(typed))
if err == nil {
return parsed
}
}
return 0
}

View File

@@ -0,0 +1,84 @@
package jvm
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"testing"
)
func withStubJMXHelperLookPath(t *testing.T, fn func(string) (string, error)) {
t.Helper()
prev := jmxHelperLookPath
jmxHelperLookPath = fn
t.Cleanup(func() {
jmxHelperLookPath = prev
})
}
func TestEnsureJMXHelperRuntimeWritesEmbeddedJar(t *testing.T) {
cacheDir := filepath.Join(t.TempDir(), "helper-cache")
t.Setenv("GONAVI_JMX_HELPER_CACHE_DIR", cacheDir)
withStubJMXHelperLookPath(t, func(name string) (string, error) {
if name == "java" {
return "/usr/bin/java", nil
}
return "", fmt.Errorf("unexpected binary lookup: %s", name)
})
runtimeInfo, err := ensureJMXHelperRuntime(context.Background())
if err != nil {
t.Fatalf("ensureJMXHelperRuntime returned error: %v", err)
}
if runtimeInfo.javaBinary != "/usr/bin/java" {
t.Fatalf("unexpected java binary: %#v", runtimeInfo)
}
if runtimeInfo.classpath == "" {
t.Fatalf("expected helper classpath, got %#v", runtimeInfo)
}
jarBytes, err := os.ReadFile(runtimeInfo.classpath)
if err != nil {
t.Fatalf("read embedded helper jar failed: %v", err)
}
if !bytes.Equal(jarBytes, embeddedJMXHelperJar) {
t.Fatalf("helper jar content mismatch: got %d bytes want %d", len(jarBytes), len(embeddedJMXHelperJar))
}
runtimeInfo2, err := ensureJMXHelperRuntime(context.Background())
if err != nil {
t.Fatalf("ensureJMXHelperRuntime second call returned error: %v", err)
}
if runtimeInfo2.classpath != runtimeInfo.classpath {
t.Fatalf("expected stable classpath, got %q and %q", runtimeInfo.classpath, runtimeInfo2.classpath)
}
}
func TestEnsureJMXHelperRuntimeUsesOverrideClasspath(t *testing.T) {
cacheDir := filepath.Join(t.TempDir(), "helper-cache")
overridePath := filepath.Join(t.TempDir(), "custom", "helper.jar")
t.Setenv("GONAVI_JMX_HELPER_CACHE_DIR", cacheDir)
t.Setenv("GONAVI_JMX_HELPER_CLASSPATH", overridePath)
withStubJMXHelperLookPath(t, func(name string) (string, error) {
if name == "java" {
return "/usr/bin/java", nil
}
return "", fmt.Errorf("unexpected binary lookup: %s", name)
})
runtimeInfo, err := ensureJMXHelperRuntime(context.Background())
if err != nil {
t.Fatalf("ensureJMXHelperRuntime returned error: %v", err)
}
if runtimeInfo.classpath != overridePath {
t.Fatalf("expected override classpath %q, got %#v", overridePath, runtimeInfo)
}
if _, err := os.Stat(cacheDir); !os.IsNotExist(err) {
t.Fatalf("expected override mode to skip cache writes, stat err=%v", err)
}
}

View File

@@ -3,14 +3,12 @@ package jvm
import (
"context"
"fmt"
"net"
"strconv"
"strings"
"time"
"GoNavi-Wails/internal/connection"
)
var jmxHelperRunner = runJMXHelper
type JMXProvider struct{}
func NewJMXProvider() Provider { return &JMXProvider{} }
@@ -18,47 +16,95 @@ func NewJMXProvider() Provider { return &JMXProvider{} }
func (p *JMXProvider) Mode() string { return ModeJMX }
func (p *JMXProvider) TestConnection(ctx context.Context, cfg connection.ConnectionConfig) error {
host := strings.TrimSpace(cfg.JVM.JMX.Host)
if host == "" {
host = strings.TrimSpace(cfg.Host)
if err := validateJMXConnection(cfg); err != nil {
return err
}
if host == "" {
return fmt.Errorf("jmx host is required")
}
port := cfg.JVM.JMX.Port
if port <= 0 {
return fmt.Errorf("jmx port is invalid: %d", port)
}
timeout := time.Duration(cfg.Timeout) * time.Second
if timeout <= 0 {
timeout = 5 * time.Second
}
dialer := net.Dialer{Timeout: timeout}
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
_, err := jmxHelperRunner(ctx, cfg, jmxHelperCommandPing, nil, nil)
if err != nil {
return fmt.Errorf("jmx tcp connect failed: %w", err)
return fmt.Errorf("jmx test connection failed: %w", err)
}
_ = conn.Close()
return nil
}
func (p *JMXProvider) ProbeCapabilities(ctx context.Context, cfg connection.ConnectionConfig) ([]Capability, error) {
return []Capability{{Mode: ModeJMX, CanBrowse: true, CanWrite: false, CanPreview: false, DisplayLabel: "JMX"}}, nil
if err := validateJMXConnection(cfg); err != nil {
return nil, err
}
readOnly := cfg.JVM.ReadOnly != nil && *cfg.JVM.ReadOnly
return []Capability{{
Mode: ModeJMX,
CanBrowse: true,
CanWrite: !readOnly,
CanPreview: true,
DisplayLabel: "JMX",
Reason: func() string {
if readOnly {
return "当前连接只读"
}
return ""
}(),
}}, nil
}
func (p *JMXProvider) ListResources(ctx context.Context, cfg connection.ConnectionConfig, parentPath string) ([]ResourceSummary, error) {
return nil, errProviderNotImplemented(p.Mode(), "list resources")
target, err := parseParentResourcePath(parentPath)
if err != nil {
return nil, err
}
resp, err := jmxHelperRunner(ctx, cfg, jmxHelperCommandList, target, nil)
if err != nil {
return nil, fmt.Errorf("jmx list resources failed: %w", err)
}
items := make([]ResourceSummary, 0, len(resp.Resources))
for _, item := range resp.Resources {
items = append(items, resourceSummaryFromHelper(item))
}
return items, nil
}
func (p *JMXProvider) GetValue(ctx context.Context, cfg connection.ConnectionConfig, resourcePath string) (ValueSnapshot, error) {
return ValueSnapshot{}, errProviderNotImplemented(p.Mode(), "get value")
target, err := parseRequiredResourcePath(resourcePath)
if err != nil {
return ValueSnapshot{}, err
}
resp, err := jmxHelperRunner(ctx, cfg, jmxHelperCommandGet, &target, nil)
if err != nil {
return ValueSnapshot{}, fmt.Errorf("jmx get value failed: %w", err)
}
return valueSnapshotFromHelper(target, resp.Snapshot)
}
func (p *JMXProvider) PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) {
return ChangePreview{}, errProviderNotImplemented(p.Mode(), "preview change")
target, err := parseRequiredResourcePath(req.ResourceID)
if err != nil {
return ChangePreview{}, err
}
resp, err := jmxHelperRunner(ctx, cfg, jmxHelperCommandPreview, &target, &req)
if err != nil {
return ChangePreview{}, fmt.Errorf("jmx preview change failed: %w", err)
}
return previewFromHelper(target, resp.Preview)
}
func (p *JMXProvider) ApplyChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ApplyResult, error) {
return ApplyResult{}, errProviderNotImplemented(p.Mode(), "apply change")
target, err := parseRequiredResourcePath(req.ResourceID)
if err != nil {
return ApplyResult{}, err
}
if req.ExpectedVersion != "" {
before, getErr := p.GetValue(ctx, cfg, req.ResourceID)
if getErr != nil {
return ApplyResult{}, getErr
}
if before.Version != "" && before.Version != req.ExpectedVersion {
return ApplyResult{}, staleVersionError(req.ResourceID, req.ExpectedVersion, before.Version)
}
}
resp, err := jmxHelperRunner(ctx, cfg, jmxHelperCommandApply, &target, &req)
if err != nil {
return ApplyResult{}, fmt.Errorf("jmx apply change failed: %w", err)
}
return applyResultFromHelper(target, resp.ApplyResult)
}

View File

@@ -0,0 +1,574 @@
package jvm
import (
"bufio"
"context"
"errors"
"fmt"
"net"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"GoNavi-Wails/internal/connection"
)
type stubJMXHelper struct {
lastRequest jmxHelperRequest
response jmxHelperResponse
err error
}
func withStubJMXHelper(
t *testing.T,
fn func(context.Context, connection.ConnectionConfig, string, *jmxResourceTarget, *ChangeRequest) (jmxHelperResponse, error),
) {
t.Helper()
prev := jmxHelperRunner
jmxHelperRunner = fn
t.Cleanup(func() {
jmxHelperRunner = prev
})
}
func (s *stubJMXHelper) run(_ context.Context, cfg connection.ConnectionConfig, command string, target *jmxResourceTarget, change *ChangeRequest) (jmxHelperResponse, error) {
s.lastRequest = jmxHelperRequest{
Command: command,
Connection: jmxHelperConnection{
Host: resolveJMXHost(cfg),
Port: resolveJMXPort(cfg),
Username: strings.TrimSpace(cfg.JVM.JMX.Username),
Password: cfg.JVM.JMX.Password,
DomainAllowlist: normalizeJMXAllowlist(cfg.JVM.JMX.DomainAllowlist),
TimeoutSeconds: int(resolveJMXTimeout(cfg).Seconds()),
},
}
if target != nil {
s.lastRequest.Target = helperTargetFromResource(*target)
}
if change != nil {
s.lastRequest.Change = &jmxHelperChangePlan{
Action: change.Action,
Reason: change.Reason,
ExpectedVersion: change.ExpectedVersion,
Payload: change.Payload,
}
}
if s.err != nil {
return jmxHelperResponse{}, s.err
}
return s.response, nil
}
func newJMXProviderTestConfig() connection.ConnectionConfig {
readOnly := false
return connection.ConnectionConfig{
Type: "jvm",
Host: "127.0.0.1",
Timeout: 5,
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
AllowedModes: []string{ModeJMX},
PreferredMode: ModeJMX,
JMX: connection.JVMJMXConfig{
Host: "127.0.0.1",
Port: 9010,
},
},
}
}
func TestJMXProviderListResourcesUsesHelperResponse(t *testing.T) {
helper := &stubJMXHelper{
response: jmxHelperResponse{
Resources: []jmxHelperResource{
{
Kind: "domain",
Name: "java.lang",
CanRead: true,
HasChildren: true,
Domain: "java.lang",
},
},
},
}
withStubJMXHelper(t, helper.run)
provider := &JMXProvider{}
items, err := provider.ListResources(context.Background(), newJMXProviderTestConfig(), "")
if err != nil {
t.Fatalf("ListResources returned error: %v", err)
}
if helper.lastRequest.Command != jmxHelperCommandList {
t.Fatalf("expected helper command %q, got %#v", jmxHelperCommandList, helper.lastRequest)
}
if len(items) != 1 || items[0].Kind != "domain" || items[0].Path == "" {
t.Fatalf("unexpected resources: %#v", items)
}
}
func TestJMXProviderGetValueUsesHelperSnapshot(t *testing.T) {
helper := &stubJMXHelper{
response: jmxHelperResponse{
Snapshot: &jmxHelperSnapshot{
Kind: "attribute",
Format: "string",
Value: "READY",
},
},
}
withStubJMXHelper(t, helper.run)
provider := &JMXProvider{}
snapshot, err := provider.GetValue(context.Background(), newJMXProviderTestConfig(), "jmx:/attribute/bean/State")
if err != nil {
t.Fatalf("GetValue returned error: %v", err)
}
if helper.lastRequest.Command != jmxHelperCommandGet {
t.Fatalf("expected helper command %q, got %#v", jmxHelperCommandGet, helper.lastRequest)
}
if snapshot.ResourceID != "jmx:/attribute/bean/State" || snapshot.Value != "READY" || snapshot.Version == "" {
t.Fatalf("unexpected snapshot: %#v", snapshot)
}
}
func TestJMXProviderPreviewAndApplyUseHelperPayload(t *testing.T) {
request := ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: "jmx:/attribute/bean/State",
Action: "set",
Reason: "repair state",
Payload: map[string]any{
"value": "READY",
},
}
previewHelper := &stubJMXHelper{
response: jmxHelperResponse{
Preview: &jmxHelperPreview{
Allowed: true,
Summary: "preview ok",
RiskLevel: "low",
Before: &jmxHelperSnapshot{
Kind: "attribute",
Format: "string",
Value: "STALE",
},
After: &jmxHelperSnapshot{
Kind: "attribute",
Format: "string",
Value: "READY",
},
},
},
}
withStubJMXHelper(t, previewHelper.run)
provider := &JMXProvider{}
preview, err := provider.PreviewChange(context.Background(), newJMXProviderTestConfig(), request)
if err != nil {
t.Fatalf("PreviewChange returned error: %v", err)
}
if previewHelper.lastRequest.Command != jmxHelperCommandPreview {
t.Fatalf("expected helper command %q, got %#v", jmxHelperCommandPreview, previewHelper.lastRequest)
}
if previewHelper.lastRequest.Change.Action != "set" || preview.Summary != "preview ok" {
t.Fatalf("unexpected preview response: %#v / %#v", preview, previewHelper.lastRequest)
}
applyHelper := &stubJMXHelper{
response: jmxHelperResponse{
ApplyResult: &jmxHelperApplyResponse{
Status: "applied",
Message: "updated",
UpdatedValue: &jmxHelperSnapshot{
Kind: "attribute",
Format: "string",
Value: "READY",
},
},
},
}
withStubJMXHelper(t, applyHelper.run)
provider = &JMXProvider{}
result, err := provider.ApplyChange(context.Background(), newJMXProviderTestConfig(), request)
if err != nil {
t.Fatalf("ApplyChange returned error: %v", err)
}
if applyHelper.lastRequest.Command != jmxHelperCommandApply {
t.Fatalf("expected helper command %q, got %#v", jmxHelperCommandApply, applyHelper.lastRequest)
}
if result.Status != "applied" || result.UpdatedValue.Value != "READY" {
t.Fatalf("unexpected apply result: %#v", result)
}
}
func TestJMXProviderWrapsHelperErrors(t *testing.T) {
helper := &stubJMXHelper{err: errors.New("helper failed")}
withStubJMXHelper(t, helper.run)
provider := &JMXProvider{}
_, err := provider.ListResources(context.Background(), newJMXProviderTestConfig(), "")
if err == nil {
t.Fatal("expected helper error")
}
if got := err.Error(); got == "" || !containsAll(got, "list", "helper failed") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestJMXProviderGetValueRejectsUnknownResourcePath(t *testing.T) {
provider := NewJMXProvider()
_, err := provider.GetValue(context.Background(), newJMXProviderTestConfig(), "bad-path")
if err == nil {
t.Fatal("expected invalid resource path to fail")
}
if !strings.Contains(strings.ToLower(err.Error()), "resource path") {
t.Fatalf("expected resource path context, got %v", err)
}
}
func TestJMXProviderRealJMXRoundTrip(t *testing.T) {
if _, err := exec.LookPath("java"); err != nil {
t.Skipf("java 不可用,跳过真实 JMX 集成测试: %v", err)
}
if _, err := exec.LookPath("javac"); err != nil {
t.Skipf("javac 不可用,跳过真实 JMX 集成测试: %v", err)
}
provider := NewJMXProvider()
fixture := startJMXFixture(t)
readOnly := false
cfg := connection.ConnectionConfig{
Type: "jvm",
Host: "127.0.0.1",
Timeout: 8,
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
PreferredMode: ModeJMX,
AllowedModes: []string{ModeJMX},
JMX: connection.JVMJMXConfig{
Host: "127.0.0.1",
Port: fixture.port,
DomainAllowlist: []string{"com.gonavi.fixture"},
},
},
}
t.Setenv("GONAVI_JMX_HELPER_CACHE_DIR", filepath.Join(t.TempDir(), "helper-cache"))
waitForTest(t, 20*time.Second, func() error {
return provider.TestConnection(context.Background(), cfg)
})
caps, err := provider.ProbeCapabilities(context.Background(), cfg)
if err != nil {
t.Fatalf("ProbeCapabilities returned error: %v", err)
}
if len(caps) != 1 || !caps[0].CanBrowse || !caps[0].CanWrite || !caps[0].CanPreview {
t.Fatalf("unexpected capabilities: %#v", caps)
}
domains, err := provider.ListResources(context.Background(), cfg, "")
if err != nil {
t.Fatalf("ListResources(root) returned error: %v", err)
}
if len(domains) != 1 || domains[0].Name != "com.gonavi.fixture" {
t.Fatalf("unexpected root resources: %#v", domains)
}
mbeans, err := provider.ListResources(context.Background(), cfg, domains[0].Path)
if err != nil {
t.Fatalf("ListResources(domain) returned error: %v", err)
}
if len(mbeans) != 1 {
t.Fatalf("expected one mbean under test domain, got %#v", mbeans)
}
mbean := mbeans[0]
children, err := provider.ListResources(context.Background(), cfg, mbean.Path)
if err != nil {
t.Fatalf("ListResources(mbean) returned error: %v", err)
}
modeAttr := findResourceByName(t, children, "Mode")
lastInvocationAttr := findResourceByName(t, children, "LastInvocation")
resizeOp := findResourceByName(t, children, "resize(int,boolean)")
modeBefore, err := provider.GetValue(context.Background(), cfg, modeAttr.Path)
if err != nil {
t.Fatalf("GetValue(mode before) returned error: %v", err)
}
if modeBefore.Value != "warm" {
t.Fatalf("expected initial mode warm, got %#v", modeBefore)
}
if strings.TrimSpace(modeBefore.Version) == "" {
t.Fatalf("expected initial mode version, got %#v", modeBefore)
}
attrPreview, err := provider.PreviewChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: modeAttr.Path,
Action: "update",
Reason: "切换缓存模式",
ExpectedVersion: modeBefore.Version,
Payload: map[string]any{
"value": "hot",
},
})
if err != nil {
t.Fatalf("PreviewChange(attribute) returned error: %v", err)
}
if !attrPreview.Allowed {
t.Fatalf("expected attribute preview allowed, got %#v", attrPreview)
}
if attrPreview.Before.Value != "warm" || attrPreview.After.Value != "hot" {
t.Fatalf("unexpected attribute preview diff: %#v", attrPreview)
}
attrApply, err := provider.ApplyChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: modeAttr.Path,
Action: "update",
Reason: "切换缓存模式",
ExpectedVersion: modeBefore.Version,
Payload: map[string]any{
"value": "hot",
},
})
if err != nil {
t.Fatalf("ApplyChange(attribute) returned error: %v", err)
}
if strings.TrimSpace(attrApply.Status) == "" || attrApply.UpdatedValue.Value != "hot" {
t.Fatalf("unexpected attribute apply result: %#v", attrApply)
}
modeAfter, err := provider.GetValue(context.Background(), cfg, modeAttr.Path)
if err != nil {
t.Fatalf("GetValue(mode after) returned error: %v", err)
}
if modeAfter.Value != "hot" {
t.Fatalf("expected mode hot after apply, got %#v", modeAfter)
}
_, err = provider.ApplyChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: modeAttr.Path,
Action: "update",
Reason: "尝试使用过期版本",
ExpectedVersion: modeBefore.Version,
Payload: map[string]any{
"value": "cold",
},
})
if err == nil {
t.Fatal("expected stale version apply to fail")
}
if !strings.Contains(strings.ToLower(err.Error()), "version") {
t.Fatalf("expected version mismatch context, got %v", err)
}
opPreview, err := provider.PreviewChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: resizeOp.Path,
Action: "invoke",
Reason: "执行 resize 操作",
Payload: map[string]any{
"args": []any{128, true},
},
})
if err != nil {
t.Fatalf("PreviewChange(operation) returned error: %v", err)
}
if !opPreview.Allowed || !strings.Contains(opPreview.Summary, "resize") {
t.Fatalf("unexpected operation preview: %#v", opPreview)
}
opApply, err := provider.ApplyChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: resizeOp.Path,
Action: "invoke",
Reason: "执行 resize 操作",
Payload: map[string]any{
"args": []any{128, true},
},
})
if err != nil {
t.Fatalf("ApplyChange(operation) returned error: %v", err)
}
if strings.TrimSpace(opApply.Status) == "" {
t.Fatalf("expected operation apply status, got %#v", opApply)
}
lastInvocation, err := provider.GetValue(context.Background(), cfg, lastInvocationAttr.Path)
if err != nil {
t.Fatalf("GetValue(last invocation) returned error: %v", err)
}
if lastInvocation.Value != "capacity=128,enabled=true" {
t.Fatalf("unexpected operation side effect snapshot: %#v", lastInvocation)
}
}
type jmxFixtureProcess struct {
port int
cmd *exec.Cmd
}
func startJMXFixture(t *testing.T) jmxFixtureProcess {
t.Helper()
javaBin, err := exec.LookPath("java")
if err != nil {
t.Fatalf("look up java failed: %v", err)
}
javacBin, err := exec.LookPath("javac")
if err != nil {
t.Fatalf("look up javac failed: %v", err)
}
classesDir := filepath.Join(t.TempDir(), "fixture-classes")
sourceRoot := filepath.Join(testRepoRoot(t), "internal", "jvm", "testdata", "jmxfixture", "src")
javaFiles, err := filepath.Glob(filepath.Join(sourceRoot, "com", "gonavi", "fixture", "*.java"))
if err != nil {
t.Fatalf("glob fixture sources failed: %v", err)
}
if len(javaFiles) == 0 {
t.Fatalf("expected fixture java files under %s", sourceRoot)
}
compileCmd := exec.Command(javacBin, append([]string{"-d", classesDir}, javaFiles...)...)
output, err := compileCmd.CombinedOutput()
if err != nil {
t.Fatalf("compile fixture failed: %v\n%s", err, strings.TrimSpace(string(output)))
}
port := reserveTCPPort(t)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
cmd := exec.CommandContext(ctx, javaBin,
fmt.Sprintf("-Dcom.sun.management.jmxremote.port=%d", port),
fmt.Sprintf("-Dcom.sun.management.jmxremote.rmi.port=%d", port),
"-Dcom.sun.management.jmxremote.authenticate=false",
"-Dcom.sun.management.jmxremote.ssl=false",
"-Dcom.sun.management.jmxremote.local.only=false",
"-Dcom.sun.management.jmxremote.host=127.0.0.1",
"-Djava.rmi.server.hostname=127.0.0.1",
"-cp", classesDir,
"com.gonavi.fixture.JMXTestServer",
)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("fixture stdout pipe failed: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("start fixture failed: %v", err)
}
t.Cleanup(func() {
cancel()
_ = cmd.Wait()
})
ready := make(chan error, 1)
go func() {
line, readErr := bufio.NewReader(stdout).ReadString('\n')
if readErr != nil {
ready <- fmt.Errorf("fixture readiness read failed: %w", readErr)
return
}
if strings.TrimSpace(line) != "READY" {
ready <- fmt.Errorf("unexpected fixture readiness line: %q", strings.TrimSpace(line))
return
}
ready <- nil
}()
select {
case err := <-ready:
if err != nil {
t.Fatalf("wait fixture ready failed: %v", err)
}
case <-time.After(20 * time.Second):
t.Fatal("fixture did not become ready within 20s")
}
waitForTest(t, 10*time.Second, func() error {
conn, dialErr := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 500*time.Millisecond)
if dialErr != nil {
return dialErr
}
_ = conn.Close()
return nil
})
return jmxFixtureProcess{port: port, cmd: cmd}
}
func waitForTest(t *testing.T, timeout time.Duration, fn func() error) {
t.Helper()
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
if err := fn(); err == nil {
return
} else {
lastErr = err
}
time.Sleep(200 * time.Millisecond)
}
if lastErr == nil {
lastErr = errors.New("condition not satisfied before timeout")
}
t.Fatalf("condition not met within %s: %v", timeout, lastErr)
}
func reserveTCPPort(t *testing.T) int {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("reserve TCP port failed: %v", err)
}
defer listener.Close()
addr, ok := listener.Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("unexpected TCP addr type: %T", listener.Addr())
}
return addr.Port
}
func findResourceByName(t *testing.T, items []ResourceSummary, name string) ResourceSummary {
t.Helper()
for _, item := range items {
if item.Name == name {
return item
}
}
t.Fatalf("resource %q not found in %#v", name, items)
return ResourceSummary{}
}
func testRepoRoot(t *testing.T) string {
t.Helper()
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller(0) failed")
}
return filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
}
func containsAll(source string, fragments ...string) bool {
for _, fragment := range fragments {
if !strings.Contains(source, fragment) {
return false
}
}
return true
}

View File

@@ -0,0 +1,15 @@
`jmx-helper-runtime.jar` is the embedded runtime used by `internal/jvm/jmx_helper.go`.
Source of truth:
- `tools/jmx-helper/src/com/gonavi/jmxhelper/*.java`
Regenerate the jar after changing helper sources:
```bash
tmpdir="$(mktemp -d)"
classes="$tmpdir/classes"
mkdir -p "$classes"
javac --release 8 -Xlint:-options -encoding UTF-8 -d "$classes" tools/jmx-helper/src/com/gonavi/jmxhelper/*.java
jar --create --file internal/jvm/jmxhelper_assets/jmx-helper-runtime.jar -C "$classes" .
rm -rf "$tmpdir"
```

Binary file not shown.

View File

@@ -21,6 +21,7 @@ type Provider interface {
var providerFactories = map[string]func() Provider{
ModeJMX: func() Provider { return NewJMXProvider() },
ModeEndpoint: func() Provider { return NewHTTPProvider() },
ModeAgent: func() Provider { return NewAgentProvider() },
}
func NewProvider(mode string) (Provider, error) {

View File

@@ -36,7 +36,7 @@ func TestJMXProviderTestConnectionReturnsErrorWhenPortInvalid(t *testing.T) {
Host: "orders.internal",
JVM: connection.JVMConfig{
JMX: connection.JVMJMXConfig{
Port: 0,
Port: -1,
},
},
})
@@ -89,14 +89,62 @@ func TestHTTPProviderTestConnectionReturnsErrorWhenBaseURLInvalid(t *testing.T)
}
}
func TestJMXProviderListResourcesReturnsNotImplementedError(t *testing.T) {
provider := NewJMXProvider()
func TestAgentProviderTestConnectionReturnsErrorWhenBaseURLMissing(t *testing.T) {
provider := NewAgentProvider()
err := provider.TestConnection(context.Background(), connection.ConnectionConfig{
Type: "jvm",
JVM: connection.JVMConfig{
Agent: connection.JVMAgentConfig{
BaseURL: "",
},
},
})
_, err := provider.ListResources(context.Background(), connection.ConnectionConfig{}, "")
if err == nil {
t.Fatal("expected not implemented error")
t.Fatal("expected error when agent baseURL is missing")
}
if !strings.Contains(strings.ToLower(err.Error()), "does not implement") {
if !strings.Contains(err.Error(), "agent baseURL is required") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestAgentProviderTestConnectionReturnsErrorWhenBaseURLInvalid(t *testing.T) {
provider := NewAgentProvider()
err := provider.TestConnection(context.Background(), connection.ConnectionConfig{
Type: "jvm",
JVM: connection.JVMConfig{
Agent: connection.JVMAgentConfig{
BaseURL: "://bad-url",
},
},
})
if err == nil {
t.Fatal("expected error when agent baseURL is invalid")
}
if !strings.Contains(err.Error(), "agent baseURL is invalid") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestJMXProviderListResourcesReturnsErrorWhenParentPathInvalid(t *testing.T) {
provider := NewJMXProvider()
_, err := provider.ListResources(context.Background(), connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
JMX: connection.JVMJMXConfig{
Port: 9010,
},
},
}, "bad-path")
if err == nil {
t.Fatal("expected invalid parent path error")
}
if !strings.Contains(strings.ToLower(err.Error()), "parent resource path") {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -0,0 +1,12 @@
package com.gonavi.fixture;
import java.util.concurrent.CountDownLatch;
public final class AgentHostApp {
private AgentHostApp() {
}
public static void main(String[] args) throws Exception {
new CountDownLatch(1).await();
}
}

View File

@@ -0,0 +1,384 @@
package com.gonavi.fixture;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.instrument.Instrumentation;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;
public final class GoNaviTestAgent {
private static final String ROOT_PATH = "/gonavi/agent/jvm";
private static final Object LOCK = new Object();
private static volatile HttpServer server;
private static volatile String apiKey = "secret-token";
private static Map<String, Object> stateValue = defaultStateValue();
private static int stateVersion = 1;
private GoNaviTestAgent() {
}
public static void premain(String args, Instrumentation inst) throws Exception {
start(args);
}
public static void agentmain(String args, Instrumentation inst) throws Exception {
start(args);
}
private static void start(String args) throws Exception {
synchronized (LOCK) {
if (server != null) {
return;
}
AgentArgs parsedArgs = parseArgs(args);
apiKey = parsedArgs.apiKey;
HttpServer nextServer = HttpServer.create(new InetSocketAddress("127.0.0.1", parsedArgs.port), 0);
nextServer.createContext(ROOT_PATH, GoNaviTestAgent::handleProbe);
nextServer.createContext(ROOT_PATH + "/resources", GoNaviTestAgent::handleResources);
nextServer.createContext(ROOT_PATH + "/value", GoNaviTestAgent::handleValue);
nextServer.createContext(ROOT_PATH + "/preview", GoNaviTestAgent::handlePreview);
nextServer.createContext(ROOT_PATH + "/apply", GoNaviTestAgent::handleApply);
nextServer.setExecutor(Executors.newCachedThreadPool());
nextServer.start();
server = nextServer;
}
System.out.println("AGENT_READY");
System.out.flush();
}
private static void handleProbe(HttpExchange exchange) throws IOException {
if (!ensureMethod(exchange, "GET", "HEAD") || !ensureApiKey(exchange)) {
return;
}
exchange.sendResponseHeaders(204, -1);
exchange.close();
}
private static void handleResources(HttpExchange exchange) throws IOException {
if (!ensureMethod(exchange, "GET") || !ensureApiKey(exchange)) {
return;
}
String parentPath = queryParam(exchange, "parentPath");
List<Map<String, Object>> resources = new ArrayList<>();
if (parentPath.isEmpty()) {
resources.add(resource("agent.cache", "", "folder", "Agent Cache", "/runtime/cache", true, true, true));
} else if ("/runtime/cache".equals(parentPath)) {
resources.add(resource("agent.cache.user1001", "agent.cache", "entry", "user:1001", "/runtime/cache/user:1001", true, true, false));
} else {
sendStatus(exchange, 404, "unknown parentPath: " + parentPath);
return;
}
sendJson(exchange, 200, resources);
}
private static void handleValue(HttpExchange exchange) throws IOException {
if (!ensureMethod(exchange, "GET") || !ensureApiKey(exchange)) {
return;
}
String resourcePath = queryParam(exchange, "resourcePath");
if ("/runtime/cache".equals(resourcePath)) {
sendJson(exchange, 200, folderSnapshot());
return;
}
if ("/runtime/cache/user:1001".equals(resourcePath)) {
synchronized (LOCK) {
sendJson(exchange, 200, entrySnapshot(cloneMap(stateValue), currentVersion()));
}
return;
}
sendStatus(exchange, 404, "unknown resourcePath: " + resourcePath);
}
private static void handlePreview(HttpExchange exchange) throws IOException {
if (!ensureMethod(exchange, "POST") || !ensureApiKey(exchange)) {
return;
}
Map<String, Object> request = readJsonBody(exchange);
String resourcePath = requiredString(request.get("resourceId"), "resourceId");
if (!"/runtime/cache/user:1001".equals(resourcePath)) {
sendStatus(exchange, 400, "preview only supports /runtime/cache/user:1001");
return;
}
Map<String, Object> payload = requiredObject(request.get("payload"), "payload");
synchronized (LOCK) {
Map<String, Object> beforeValue = cloneMap(stateValue);
Map<String, Object> afterValue = mergeState(beforeValue, payload);
Map<String, Object> preview = new LinkedHashMap<>();
preview.put("allowed", Boolean.TRUE);
preview.put("summary", "preview agent cache update");
preview.put("riskLevel", "medium");
preview.put("before", entrySnapshot(beforeValue, currentVersion()));
preview.put("after", entrySnapshot(afterValue, currentVersion() + "-preview"));
sendJson(exchange, 200, preview);
}
}
private static void handleApply(HttpExchange exchange) throws IOException {
if (!ensureMethod(exchange, "POST") || !ensureApiKey(exchange)) {
return;
}
Map<String, Object> request = readJsonBody(exchange);
String resourcePath = requiredString(request.get("resourceId"), "resourceId");
if (!"/runtime/cache/user:1001".equals(resourcePath)) {
sendStatus(exchange, 400, "apply only supports /runtime/cache/user:1001");
return;
}
Map<String, Object> payload = requiredObject(request.get("payload"), "payload");
String expectedVersion = optionalString(request.get("expectedVersion"));
synchronized (LOCK) {
String currentVersion = currentVersion();
if (!expectedVersion.isEmpty() && !expectedVersion.equals(currentVersion)) {
sendStatus(exchange, 409, "stale version: expected " + expectedVersion + " but current is " + currentVersion);
return;
}
stateValue = mergeState(stateValue, payload);
stateVersion += 1;
Map<String, Object> result = new LinkedHashMap<>();
result.put("status", "applied");
result.put("message", "agent cache updated");
result.put("updatedValue", entrySnapshot(cloneMap(stateValue), currentVersion()));
sendJson(exchange, 200, result);
}
}
private static boolean ensureMethod(HttpExchange exchange, String... methods) throws IOException {
String actual = exchange.getRequestMethod();
for (String method : methods) {
if (method.equalsIgnoreCase(actual)) {
return true;
}
}
exchange.getResponseHeaders().set("Allow", String.join(", ", methods));
sendStatus(exchange, 405, "unsupported method: " + actual);
return false;
}
private static boolean ensureApiKey(HttpExchange exchange) throws IOException {
String requestApiKey = exchange.getRequestHeaders().getFirst("X-API-Key");
if (apiKey.equals(requestApiKey)) {
return true;
}
sendStatus(exchange, 401, "missing or invalid api key");
return false;
}
private static String queryParam(HttpExchange exchange, String key) {
String rawQuery = exchange.getRequestURI().getRawQuery();
if (rawQuery == null || rawQuery.isEmpty()) {
return "";
}
String[] pairs = rawQuery.split("&");
for (String pair : pairs) {
String[] parts = pair.split("=", 2);
String name = decode(parts[0]);
if (!key.equals(name)) {
continue;
}
return parts.length > 1 ? decode(parts[1]) : "";
}
return "";
}
private static Map<String, Object> readJsonBody(HttpExchange exchange) throws IOException {
try (InputStream inputStream = exchange.getRequestBody()) {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] chunk = new byte[4096];
int read;
while ((read = inputStream.read(chunk)) >= 0) {
buffer.write(chunk, 0, read);
}
String payload = new String(buffer.toByteArray(), StandardCharsets.UTF_8);
Object parsed = MiniJson.parse(payload);
if (!(parsed instanceof Map<?, ?>)) {
throw new IllegalArgumentException("request body must be a JSON object");
}
@SuppressWarnings("unchecked")
Map<String, Object> request = (Map<String, Object>) parsed;
return request;
}
}
private static void sendStatus(HttpExchange exchange, int statusCode, String message) throws IOException {
byte[] body = message.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
exchange.sendResponseHeaders(statusCode, body.length);
exchange.getResponseBody().write(body);
exchange.close();
}
private static void sendJson(HttpExchange exchange, int statusCode, Object payload) throws IOException {
byte[] body = MiniJson.stringify(payload).getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(statusCode, body.length);
exchange.getResponseBody().write(body);
exchange.close();
}
private static Map<String, Object> folderSnapshot() {
Map<String, Object> value = new LinkedHashMap<>();
value.put("name", "Agent Cache");
value.put("entryCount", 1);
value.put("resourcePath", "/runtime/cache");
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("resourceId", "/runtime/cache");
snapshot.put("kind", "folder");
snapshot.put("format", "json");
snapshot.put("version", "agent-folder-v1");
snapshot.put("value", value);
snapshot.put("description", "agent cache root");
return snapshot;
}
private static Map<String, Object> entrySnapshot(Map<String, Object> value, String version) {
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("resourceId", "/runtime/cache/user:1001");
snapshot.put("kind", "entry");
snapshot.put("format", "json");
snapshot.put("version", version);
snapshot.put("value", value);
snapshot.put("description", "agent cache entry");
snapshot.put("supportedActions", supportedActions());
return snapshot;
}
private static List<Map<String, Object>> supportedActions() {
List<Map<String, Object>> actions = new ArrayList<>();
Map<String, Object> action = new LinkedHashMap<>();
action.put("action", "put");
action.put("label", "Update Cache Entry");
action.put("description", "Merge payload fields into the current cache entry");
actions.add(action);
return actions;
}
private static Map<String, Object> resource(
String id,
String parentId,
String kind,
String name,
String path,
boolean canRead,
boolean canWrite,
boolean hasChildren
) {
Map<String, Object> resource = new LinkedHashMap<>();
resource.put("id", id);
resource.put("parentId", parentId);
resource.put("kind", kind);
resource.put("name", name);
resource.put("path", path);
resource.put("providerMode", "agent");
resource.put("canRead", Boolean.valueOf(canRead));
resource.put("canWrite", Boolean.valueOf(canWrite));
resource.put("hasChildren", Boolean.valueOf(hasChildren));
return resource;
}
private static Map<String, Object> defaultStateValue() {
Map<String, Object> value = new LinkedHashMap<>();
value.put("status", "cold");
value.put("score", Integer.valueOf(60));
value.put("owner", "agent");
return value;
}
private static Map<String, Object> cloneMap(Map<String, Object> input) {
Map<String, Object> copy = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : input.entrySet()) {
copy.put(entry.getKey(), entry.getValue());
}
return copy;
}
private static Map<String, Object> mergeState(Map<String, Object> current, Map<String, Object> payload) {
Map<String, Object> merged = cloneMap(current);
for (Map.Entry<String, Object> entry : payload.entrySet()) {
merged.put(entry.getKey(), entry.getValue());
}
return merged;
}
private static String currentVersion() {
return "agent-v" + stateVersion;
}
private static String requiredString(Object value, String fieldName) {
String text = optionalString(value);
if (text.isEmpty()) {
throw new IllegalArgumentException(fieldName + " is required");
}
return text;
}
private static String optionalString(Object value) {
return value == null ? "" : String.valueOf(value).trim();
}
private static Map<String, Object> requiredObject(Object value, String fieldName) {
if (!(value instanceof Map<?, ?>)) {
throw new IllegalArgumentException(fieldName + " must be an object");
}
@SuppressWarnings("unchecked")
Map<String, Object> object = (Map<String, Object>) value;
return object;
}
private static String decode(String value) {
return URLDecoder.decode(value, StandardCharsets.UTF_8);
}
private static AgentArgs parseArgs(String rawArgs) {
AgentArgs args = new AgentArgs();
args.port = 19090;
args.apiKey = "secret-token";
if (rawArgs == null || rawArgs.trim().isEmpty()) {
return args;
}
String[] parts = rawArgs.split(",");
for (String part : parts) {
String[] keyValue = part.split("=", 2);
String key = keyValue[0].trim();
String value = keyValue.length > 1 ? keyValue[1].trim() : "";
if ("port".equalsIgnoreCase(key) && !value.isEmpty()) {
args.port = Integer.parseInt(value);
} else if ("token".equalsIgnoreCase(key) && !value.isEmpty()) {
args.apiKey = value;
}
}
return args;
}
private static final class AgentArgs {
private int port;
private String apiKey;
}
}

View File

@@ -0,0 +1,323 @@
package com.gonavi.fixture;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
final class MiniJson {
private MiniJson() {
}
static Object parse(String text) {
Parser parser = new Parser(text);
Object value = parser.parseValue();
parser.skipWhitespace();
if (!parser.isDone()) {
throw new IllegalArgumentException("JSON parsing did not consume the full payload");
}
return value;
}
static String stringify(Object value) {
StringBuilder builder = new StringBuilder();
writeValue(builder, value);
return builder.toString();
}
private static void writeValue(StringBuilder builder, Object value) {
if (value == null) {
builder.append("null");
return;
}
if (value instanceof String) {
writeString(builder, (String) value);
return;
}
if (value instanceof Number || value instanceof Boolean) {
builder.append(String.valueOf(value));
return;
}
if (value instanceof Map<?, ?>) {
builder.append('{');
Iterator<? extends Map.Entry<?, ?>> iterator = ((Map<?, ?>) value).entrySet().iterator();
boolean first = true;
while (iterator.hasNext()) {
Map.Entry<?, ?> entry = iterator.next();
if (!first) {
builder.append(',');
}
first = false;
writeString(builder, String.valueOf(entry.getKey()));
builder.append(':');
writeValue(builder, entry.getValue());
}
builder.append('}');
return;
}
if (value instanceof Iterable<?>) {
builder.append('[');
Iterator<?> iterator = ((Iterable<?>) value).iterator();
boolean first = true;
while (iterator.hasNext()) {
if (!first) {
builder.append(',');
}
first = false;
writeValue(builder, iterator.next());
}
builder.append(']');
return;
}
if (value.getClass().isArray()) {
builder.append('[');
int length = java.lang.reflect.Array.getLength(value);
for (int index = 0; index < length; index++) {
if (index > 0) {
builder.append(',');
}
writeValue(builder, java.lang.reflect.Array.get(value, index));
}
builder.append(']');
return;
}
writeString(builder, String.valueOf(value));
}
private static void writeString(StringBuilder builder, String value) {
builder.append('"');
for (int index = 0; index < value.length(); index++) {
char ch = value.charAt(index);
switch (ch) {
case '"':
builder.append("\\\"");
break;
case '\\':
builder.append("\\\\");
break;
case '\b':
builder.append("\\b");
break;
case '\f':
builder.append("\\f");
break;
case '\n':
builder.append("\\n");
break;
case '\r':
builder.append("\\r");
break;
case '\t':
builder.append("\\t");
break;
default:
if (ch < 0x20) {
builder.append(String.format("\\u%04x", (int) ch));
} else {
builder.append(ch);
}
break;
}
}
builder.append('"');
}
private static final class Parser {
private final String text;
private int index;
private Parser(String text) {
this.text = text == null ? "" : text;
this.index = 0;
}
private Object parseValue() {
skipWhitespace();
if (isDone()) {
throw new IllegalArgumentException("unexpected end of JSON input");
}
char ch = text.charAt(index);
switch (ch) {
case '{':
return parseObject();
case '[':
return parseArray();
case '"':
return parseString();
case 't':
expect("true");
return Boolean.TRUE;
case 'f':
expect("false");
return Boolean.FALSE;
case 'n':
expect("null");
return null;
default:
if (ch == '-' || Character.isDigit(ch)) {
return parseNumber();
}
throw new IllegalArgumentException("unexpected JSON token at index " + index);
}
}
private Map<String, Object> parseObject() {
expect("{");
LinkedHashMap<String, Object> result = new LinkedHashMap<>();
skipWhitespace();
if (peek('}')) {
expect("}");
return result;
}
while (true) {
String key = parseString();
skipWhitespace();
expect(":");
Object value = parseValue();
result.put(key, value);
skipWhitespace();
if (peek('}')) {
expect("}");
return result;
}
expect(",");
}
}
private List<Object> parseArray() {
expect("[");
ArrayList<Object> result = new ArrayList<>();
skipWhitespace();
if (peek(']')) {
expect("]");
return result;
}
while (true) {
result.add(parseValue());
skipWhitespace();
if (peek(']')) {
expect("]");
return result;
}
expect(",");
}
}
private String parseString() {
expect("\"");
StringBuilder builder = new StringBuilder();
while (!isDone()) {
char ch = text.charAt(index++);
if (ch == '"') {
return builder.toString();
}
if (ch != '\\') {
builder.append(ch);
continue;
}
if (isDone()) {
throw new IllegalArgumentException("unterminated escape sequence");
}
char escaped = text.charAt(index++);
switch (escaped) {
case '"':
case '\\':
case '/':
builder.append(escaped);
break;
case 'b':
builder.append('\b');
break;
case 'f':
builder.append('\f');
break;
case 'n':
builder.append('\n');
break;
case 'r':
builder.append('\r');
break;
case 't':
builder.append('\t');
break;
case 'u':
if (index + 4 > text.length()) {
throw new IllegalArgumentException("invalid unicode escape");
}
builder.append((char) Integer.parseInt(text.substring(index, index + 4), 16));
index += 4;
break;
default:
throw new IllegalArgumentException("unsupported escape sequence: \\" + escaped);
}
}
throw new IllegalArgumentException("unterminated JSON string");
}
private Number parseNumber() {
int start = index;
if (peek('-')) {
index += 1;
}
while (!isDone() && Character.isDigit(text.charAt(index))) {
index += 1;
}
boolean decimal = false;
if (!isDone() && text.charAt(index) == '.') {
decimal = true;
index += 1;
while (!isDone() && Character.isDigit(text.charAt(index))) {
index += 1;
}
}
if (!isDone() && (text.charAt(index) == 'e' || text.charAt(index) == 'E')) {
decimal = true;
index += 1;
if (!isDone() && (text.charAt(index) == '+' || text.charAt(index) == '-')) {
index += 1;
}
while (!isDone() && Character.isDigit(text.charAt(index))) {
index += 1;
}
}
String value = text.substring(start, index);
if (decimal) {
return Double.valueOf(value);
}
try {
return Integer.valueOf(value);
} catch (NumberFormatException ignored) {
return Long.valueOf(value);
}
}
private void expect(String token) {
if (!text.startsWith(token, index)) {
throw new IllegalArgumentException("expected token " + token + " at index " + index);
}
index += token.length();
}
private void skipWhitespace() {
while (!isDone()) {
char ch = text.charAt(index);
if (ch == ' ' || ch == '\n' || ch == '\r' || ch == '\t') {
index += 1;
continue;
}
break;
}
}
private boolean isDone() {
return index >= text.length();
}
private boolean peek(char ch) {
return !isDone() && text.charAt(index) == ch;
}
}
}

View File

@@ -0,0 +1,358 @@
package com.gonavi.fixture;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
public final class EndpointTestServer {
private static final String API_KEY = "secret-token";
private static final String ROOT_PATH = "/manage/jvm";
private static final Object LOCK = new Object();
private static Map<String, Object> stateValue = defaultStateValue();
private static int stateVersion = 1;
private EndpointTestServer() {
}
public static void main(String[] args) throws Exception {
int port = args.length > 0 ? Integer.parseInt(args[0]) : 19010;
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0);
server.createContext(ROOT_PATH, EndpointTestServer::handleProbe);
server.createContext(ROOT_PATH + "/resources", EndpointTestServer::handleResources);
server.createContext(ROOT_PATH + "/value", EndpointTestServer::handleValue);
server.createContext(ROOT_PATH + "/preview", EndpointTestServer::handlePreview);
server.createContext(ROOT_PATH + "/apply", EndpointTestServer::handleApply);
server.setExecutor(Executors.newCachedThreadPool());
server.start();
System.out.println("READY");
System.out.flush();
new CountDownLatch(1).await();
}
private static void handleProbe(HttpExchange exchange) throws IOException {
if (!ensureMethod(exchange, "GET", "HEAD") || !ensureApiKey(exchange)) {
return;
}
exchange.sendResponseHeaders(204, -1);
exchange.close();
}
private static void handleResources(HttpExchange exchange) throws IOException {
if (!ensureMethod(exchange, "GET") || !ensureApiKey(exchange)) {
return;
}
String parentPath = queryParam(exchange, "parentPath");
List<Map<String, Object>> resources = new ArrayList<>();
if (parentPath.isEmpty()) {
resources.add(resource("cache.orders", "", "folder", "Orders", "/cache/orders", true, true, true));
} else if ("/cache/orders".equals(parentPath)) {
resources.add(resource("cache.orders.state", "cache.orders", "entry", "State", "/cache/orders/state", true, true, false));
} else {
sendStatus(exchange, 404, "unknown parentPath: " + parentPath);
return;
}
sendJson(exchange, 200, resources);
}
private static void handleValue(HttpExchange exchange) throws IOException {
if (!ensureMethod(exchange, "GET") || !ensureApiKey(exchange)) {
return;
}
String resourcePath = queryParam(exchange, "resourcePath");
if ("/cache/orders".equals(resourcePath)) {
sendJson(exchange, 200, folderSnapshot());
return;
}
if ("/cache/orders/state".equals(resourcePath)) {
synchronized (LOCK) {
sendJson(exchange, 200, entrySnapshot(cloneMap(stateValue), currentVersion()));
}
return;
}
sendStatus(exchange, 404, "unknown resourcePath: " + resourcePath);
}
private static void handlePreview(HttpExchange exchange) throws IOException {
if (!ensureMethod(exchange, "POST") || !ensureApiKey(exchange)) {
return;
}
Map<String, Object> request = readJsonBody(exchange);
String resourcePath = requiredString(request.get("resourceId"), "resourceId");
if (!"/cache/orders/state".equals(resourcePath)) {
sendStatus(exchange, 400, "preview only supports /cache/orders/state");
return;
}
Map<String, Object> payload = requiredObject(request.get("payload"), "payload");
synchronized (LOCK) {
Map<String, Object> beforeValue = cloneMap(stateValue);
Map<String, Object> afterValue = mergeState(beforeValue, payload);
Map<String, Object> preview = new LinkedHashMap<>();
preview.put("allowed", Boolean.TRUE);
preview.put("summary", "preview orders cache state update");
preview.put("riskLevel", "medium");
preview.put("before", entrySnapshot(beforeValue, currentVersion()));
preview.put("after", entrySnapshot(afterValue, currentVersion() + "-preview"));
sendJson(exchange, 200, preview);
}
}
private static void handleApply(HttpExchange exchange) throws IOException {
if (!ensureMethod(exchange, "POST") || !ensureApiKey(exchange)) {
return;
}
Map<String, Object> request = readJsonBody(exchange);
String resourcePath = requiredString(request.get("resourceId"), "resourceId");
if (!"/cache/orders/state".equals(resourcePath)) {
sendStatus(exchange, 400, "apply only supports /cache/orders/state");
return;
}
Map<String, Object> payload = requiredObject(request.get("payload"), "payload");
String expectedVersion = optionalString(request.get("expectedVersion"));
synchronized (LOCK) {
String currentVersion = currentVersion();
if (!expectedVersion.isEmpty() && !expectedVersion.equals(currentVersion)) {
sendStatus(exchange, 409, "stale version: expected " + expectedVersion + " but current is " + currentVersion);
return;
}
stateValue = mergeState(stateValue, payload);
stateVersion += 1;
Map<String, Object> result = new LinkedHashMap<>();
result.put("status", "applied");
result.put("message", "orders cache state updated");
result.put("updatedValue", entrySnapshot(cloneMap(stateValue), currentVersion()));
sendJson(exchange, 200, result);
}
}
private static boolean ensureMethod(HttpExchange exchange, String... methods) throws IOException {
String actual = exchange.getRequestMethod();
for (String method : methods) {
if (method.equalsIgnoreCase(actual)) {
return true;
}
}
exchange.getResponseHeaders().set("Allow", String.join(", ", methods));
sendStatus(exchange, 405, "unsupported method: " + actual);
return false;
}
private static boolean ensureApiKey(HttpExchange exchange) throws IOException {
String apiKey = exchange.getRequestHeaders().getFirst("X-API-Key");
if (API_KEY.equals(apiKey)) {
return true;
}
sendStatus(exchange, 401, "missing or invalid api key");
return false;
}
private static String queryParam(HttpExchange exchange, String key) {
String rawQuery = exchange.getRequestURI().getRawQuery();
if (rawQuery == null || rawQuery.isEmpty()) {
return "";
}
String[] pairs = rawQuery.split("&");
for (String pair : pairs) {
String[] parts = pair.split("=", 2);
String name = decode(parts[0]);
if (!key.equals(name)) {
continue;
}
return parts.length > 1 ? decode(parts[1]) : "";
}
return "";
}
private static Map<String, Object> readJsonBody(HttpExchange exchange) throws IOException {
try (InputStream inputStream = exchange.getRequestBody()) {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] chunk = new byte[4096];
int read;
while ((read = inputStream.read(chunk)) >= 0) {
buffer.write(chunk, 0, read);
}
String payload = new String(buffer.toByteArray(), StandardCharsets.UTF_8);
Object parsed = MiniJson.parse(payload);
if (!(parsed instanceof Map<?, ?>)) {
throw new IllegalArgumentException("request body must be a JSON object");
}
@SuppressWarnings("unchecked")
Map<String, Object> request = (Map<String, Object>) parsed;
return request;
}
}
private static void sendStatus(HttpExchange exchange, int statusCode, String message) throws IOException {
byte[] body = message.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
exchange.sendResponseHeaders(statusCode, body.length);
exchange.getResponseBody().write(body);
exchange.close();
}
private static void sendJson(HttpExchange exchange, int statusCode, Object payload) throws IOException {
byte[] body = MiniJson.stringify(payload).getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(statusCode, body.length);
exchange.getResponseBody().write(body);
exchange.close();
}
private static Map<String, Object> folderSnapshot() {
Map<String, Object> value = new LinkedHashMap<>();
value.put("name", "Orders");
value.put("entryCount", 1);
value.put("resourcePath", "/cache/orders");
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("resourceId", "/cache/orders");
snapshot.put("kind", "folder");
snapshot.put("format", "json");
snapshot.put("version", "folder-v1");
snapshot.put("value", value);
snapshot.put("description", "orders cache root");
return snapshot;
}
private static Map<String, Object> entrySnapshot(Map<String, Object> value, String version) {
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("resourceId", "/cache/orders/state");
snapshot.put("kind", "entry");
snapshot.put("format", "json");
snapshot.put("version", version);
snapshot.put("value", value);
snapshot.put("description", "orders cache state");
snapshot.put("supportedActions", supportedActions());
return snapshot;
}
private static List<Map<String, Object>> supportedActions() {
List<Map<String, Object>> actions = new ArrayList<>();
Map<String, Object> action = new LinkedHashMap<>();
action.put("action", "put");
action.put("label", "更新缓存状态");
action.put("description", "将 payload 字段合并到当前订单缓存状态");
action.put("payloadFields", Arrays.asList(
payloadField("status", "string", false, "缓存状态"),
payloadField("lastUpdated", "string", false, "更新时间"),
payloadField("size", "number", false, "示例大小")
));
action.put("payloadExample", defaultStateValue());
actions.add(action);
return actions;
}
private static Map<String, Object> payloadField(String name, String type, boolean required, String description) {
Map<String, Object> field = new LinkedHashMap<>();
field.put("name", name);
field.put("type", type);
field.put("required", required);
field.put("description", description);
return field;
}
private static Map<String, Object> resource(
String id,
String parentId,
String kind,
String name,
String path,
boolean canRead,
boolean canWrite,
boolean hasChildren
) {
Map<String, Object> resource = new LinkedHashMap<>();
resource.put("id", id);
if (!parentId.isEmpty()) {
resource.put("parentId", parentId);
}
resource.put("kind", kind);
resource.put("name", name);
resource.put("path", path);
resource.put("providerMode", "endpoint");
resource.put("canRead", canRead);
resource.put("canWrite", canWrite);
resource.put("hasChildren", hasChildren);
return resource;
}
private static Map<String, Object> defaultStateValue() {
Map<String, Object> value = new LinkedHashMap<>();
value.put("status", "warm");
value.put("lastUpdated", "initial");
value.put("size", 7);
return value;
}
private static Map<String, Object> mergeState(Map<String, Object> current, Map<String, Object> payload) {
Map<String, Object> merged = cloneMap(current);
for (Map.Entry<String, Object> entry : payload.entrySet()) {
merged.put(entry.getKey(), entry.getValue());
}
return merged;
}
private static Map<String, Object> cloneMap(Map<String, Object> source) {
Map<String, Object> result = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : source.entrySet()) {
result.put(entry.getKey(), entry.getValue());
}
return result;
}
private static String currentVersion() {
return "state-v" + stateVersion;
}
private static String decode(String value) {
return URLDecoder.decode(value, StandardCharsets.UTF_8);
}
private static Map<String, Object> requiredObject(Object value, String field) {
if (!(value instanceof Map<?, ?>)) {
throw new IllegalArgumentException(field + " must be an object");
}
@SuppressWarnings("unchecked")
Map<String, Object> result = (Map<String, Object>) value;
return result;
}
private static String requiredString(Object value, String field) {
String resolved = optionalString(value);
if (resolved.isEmpty()) {
throw new IllegalArgumentException(field + " is required");
}
return resolved;
}
private static String optionalString(Object value) {
return value == null ? "" : String.valueOf(value).trim();
}
}

View File

@@ -0,0 +1,311 @@
package com.gonavi.fixture;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
final class MiniJson {
private MiniJson() {
}
static Object parse(String text) {
Parser parser = new Parser(text);
Object value = parser.parseValue();
parser.skipWhitespace();
if (!parser.isDone()) {
throw new IllegalArgumentException("JSON parsing did not consume the full payload");
}
return value;
}
static String stringify(Object value) {
StringBuilder builder = new StringBuilder();
writeValue(builder, value);
return builder.toString();
}
private static void writeValue(StringBuilder builder, Object value) {
if (value == null) {
builder.append("null");
return;
}
if (value instanceof String) {
writeString(builder, (String) value);
return;
}
if (value instanceof Number || value instanceof Boolean) {
builder.append(String.valueOf(value));
return;
}
if (value instanceof Map<?, ?>) {
builder.append('{');
Iterator<? extends Map.Entry<?, ?>> iterator = ((Map<?, ?>) value).entrySet().iterator();
boolean first = true;
while (iterator.hasNext()) {
Map.Entry<?, ?> entry = iterator.next();
if (!first) {
builder.append(',');
}
first = false;
writeString(builder, String.valueOf(entry.getKey()));
builder.append(':');
writeValue(builder, entry.getValue());
}
builder.append('}');
return;
}
if (value instanceof Iterable<?>) {
builder.append('[');
Iterator<?> iterator = ((Iterable<?>) value).iterator();
boolean first = true;
while (iterator.hasNext()) {
if (!first) {
builder.append(',');
}
first = false;
writeValue(builder, iterator.next());
}
builder.append(']');
return;
}
if (value.getClass().isArray()) {
builder.append('[');
int length = java.lang.reflect.Array.getLength(value);
for (int index = 0; index < length; index++) {
if (index > 0) {
builder.append(',');
}
writeValue(builder, java.lang.reflect.Array.get(value, index));
}
builder.append(']');
return;
}
writeString(builder, String.valueOf(value));
}
private static void writeString(StringBuilder builder, String value) {
builder.append('"');
for (int index = 0; index < value.length(); index++) {
char ch = value.charAt(index);
switch (ch) {
case '"':
builder.append("\\\"");
break;
case '\\':
builder.append("\\\\");
break;
case '\b':
builder.append("\\b");
break;
case '\f':
builder.append("\\f");
break;
case '\n':
builder.append("\\n");
break;
case '\r':
builder.append("\\r");
break;
case '\t':
builder.append("\\t");
break;
default:
if (ch < 0x20) {
builder.append(String.format("\\u%04x", (int) ch));
} else {
builder.append(ch);
}
break;
}
}
builder.append('"');
}
private static final class Parser {
private final String text;
private int index;
private Parser(String text) {
this.text = text == null ? "" : text;
this.index = 0;
}
private Object parseValue() {
skipWhitespace();
if (isDone()) {
throw new IllegalArgumentException("unexpected end of JSON input");
}
char ch = text.charAt(index);
switch (ch) {
case '{':
return parseObject();
case '[':
return parseArray();
case '"':
return parseString();
case 't':
expect("true");
return Boolean.TRUE;
case 'f':
expect("false");
return Boolean.FALSE;
case 'n':
expect("null");
return null;
default:
if (ch == '-' || Character.isDigit(ch)) {
return parseNumber();
}
throw new IllegalArgumentException("unexpected JSON token at index " + index);
}
}
private Map<String, Object> parseObject() {
expect("{");
LinkedHashMap<String, Object> result = new LinkedHashMap<>();
skipWhitespace();
if (peek('}')) {
expect("}");
return result;
}
while (true) {
String key = parseString();
skipWhitespace();
expect(":");
Object value = parseValue();
result.put(key, value);
skipWhitespace();
if (peek('}')) {
expect("}");
return result;
}
expect(",");
}
}
private List<Object> parseArray() {
expect("[");
ArrayList<Object> result = new ArrayList<>();
skipWhitespace();
if (peek(']')) {
expect("]");
return result;
}
while (true) {
result.add(parseValue());
skipWhitespace();
if (peek(']')) {
expect("]");
return result;
}
expect(",");
}
}
private String parseString() {
expect("\"");
StringBuilder builder = new StringBuilder();
while (!isDone()) {
char ch = text.charAt(index++);
if (ch == '"') {
return builder.toString();
}
if (ch != '\\') {
builder.append(ch);
continue;
}
if (isDone()) {
throw new IllegalArgumentException("unterminated escape sequence");
}
char escaped = text.charAt(index++);
switch (escaped) {
case '"':
case '\\':
case '/':
builder.append(escaped);
break;
case 'b':
builder.append('\b');
break;
case 'f':
builder.append('\f');
break;
case 'n':
builder.append('\n');
break;
case 'r':
builder.append('\r');
break;
case 't':
builder.append('\t');
break;
case 'u':
if (index + 4 > text.length()) {
throw new IllegalArgumentException("invalid unicode escape");
}
builder.append((char) Integer.parseInt(text.substring(index, index + 4), 16));
index += 4;
break;
default:
throw new IllegalArgumentException("unsupported escape sequence: \\" + escaped);
}
}
throw new IllegalArgumentException("unterminated JSON string");
}
private Number parseNumber() {
int start = index;
if (peek('-')) {
index += 1;
}
while (!isDone() && Character.isDigit(text.charAt(index))) {
index += 1;
}
if (!isDone() && text.charAt(index) == '.') {
index += 1;
while (!isDone() && Character.isDigit(text.charAt(index))) {
index += 1;
}
}
if (!isDone() && (text.charAt(index) == 'e' || text.charAt(index) == 'E')) {
index += 1;
if (!isDone() && (text.charAt(index) == '+' || text.charAt(index) == '-')) {
index += 1;
}
while (!isDone() && Character.isDigit(text.charAt(index))) {
index += 1;
}
}
String raw = text.substring(start, index);
if (raw.indexOf('.') >= 0 || raw.indexOf('e') >= 0 || raw.indexOf('E') >= 0) {
return Double.parseDouble(raw);
}
return Long.parseLong(raw);
}
private void expect(String token) {
skipWhitespace();
if (!text.startsWith(token, index)) {
throw new IllegalArgumentException("expected " + token + " at index " + index);
}
index += token.length();
}
private boolean peek(char ch) {
return !isDone() && text.charAt(index) == ch;
}
private void skipWhitespace() {
while (!isDone() && Character.isWhitespace(text.charAt(index))) {
index += 1;
}
}
private boolean isDone() {
return index >= text.length();
}
}
}

View File

@@ -0,0 +1,34 @@
package com.gonavi.fixture;
public final class CacheSettings implements CacheSettingsMBean {
private volatile String mode = "warm";
private final int hitCount = 7;
private volatile String lastInvocation = "none";
@Override
public String getMode() {
return mode;
}
@Override
public void setMode(String mode) {
this.mode = mode;
}
@Override
public int getHitCount() {
return hitCount;
}
@Override
public String getLastInvocation() {
return lastInvocation;
}
@Override
public String resize(int capacity, boolean enabled) {
this.lastInvocation = "capacity=" + capacity + ",enabled=" + enabled;
this.mode = enabled ? "enabled-" + capacity : "disabled-" + capacity;
return this.lastInvocation;
}
}

View File

@@ -0,0 +1,12 @@
package com.gonavi.fixture;
public interface CacheSettingsMBean {
String getMode();
void setMode(String mode);
int getHitCount();
String getLastInvocation();
String resize(int capacity, boolean enabled);
}

View File

@@ -0,0 +1,24 @@
package com.gonavi.fixture;
import java.lang.management.ManagementFactory;
import java.util.concurrent.CountDownLatch;
import javax.management.MBeanServer;
import javax.management.ObjectName;
public final class JMXTestServer {
private JMXTestServer() {
}
public static void main(String[] args) throws Exception {
MBeanServer server = ManagementFactory.getPlatformMBeanServer();
ObjectName objectName = new ObjectName("com.gonavi.fixture:type=CacheSettings,name=PrimaryCache");
if (!server.isRegistered(objectName)) {
server.registerMBean(new CacheSettings(), objectName);
}
System.out.println("READY");
System.out.flush();
new CountDownLatch(1).await();
}
}

View File

@@ -29,13 +29,32 @@ type ResourceSummary struct {
Sensitive bool `json:"sensitive,omitempty"`
}
type ActionPayloadField struct {
Name string `json:"name"`
Type string `json:"type,omitempty"`
Required bool `json:"required,omitempty"`
Description string `json:"description,omitempty"`
}
type ActionDefinition struct {
Action string `json:"action"`
Label string `json:"label,omitempty"`
Description string `json:"description,omitempty"`
Dangerous bool `json:"dangerous,omitempty"`
PayloadFields []ActionPayloadField `json:"payloadFields,omitempty"`
PayloadExample map[string]any `json:"payloadExample,omitempty"`
}
type ValueSnapshot struct {
ResourceID string `json:"resourceId"`
Kind string `json:"kind"`
Format string `json:"format"`
Version string `json:"version,omitempty"`
Value interface{} `json:"value"`
Metadata map[string]any `json:"metadata,omitempty"`
ResourceID string `json:"resourceId"`
Kind string `json:"kind"`
Format string `json:"format"`
Version string `json:"version,omitempty"`
Value interface{} `json:"value"`
Description string `json:"description,omitempty"`
Sensitive bool `json:"sensitive,omitempty"`
SupportedActions []ActionDefinition `json:"supportedActions,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type ChangeRequest struct {
@@ -43,6 +62,7 @@ type ChangeRequest struct {
ResourceID string `json:"resourceId"`
Action string `json:"action"`
Reason string `json:"reason"`
Source string `json:"source,omitempty"`
ExpectedVersion string `json:"expectedVersion,omitempty"`
Payload map[string]any `json:"payload,omitempty"`
}
@@ -70,5 +90,6 @@ type AuditRecord struct {
ResourceID string `json:"resourceId"`
Action string `json:"action"`
Reason string `json:"reason"`
Source string `json:"source,omitempty"`
Result string `json:"result"`
}

View File

@@ -0,0 +1,46 @@
package com.gonavi.jmxhelper;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
public final class JmxHelperMain {
private JmxHelperMain() {
}
public static void main(String[] args) throws Exception {
Map<String, Object> response = new LinkedHashMap<>();
try {
Map<String, Object> request = readRequest(System.in);
response.putAll(JmxRuntime.handle(request));
response.put("ok", Boolean.TRUE);
} catch (Throwable error) {
response.clear();
response.put("ok", Boolean.FALSE);
response.put("error", error.getMessage() == null ? error.getClass().getName() : error.getMessage());
Map<String, Object> details = new LinkedHashMap<>();
details.put("exception", error.getClass().getName());
response.put("details", details);
}
System.out.print(MiniJson.stringify(response));
System.out.flush();
}
@SuppressWarnings("unchecked")
private static Map<String, Object> readRequest(InputStream inputStream) throws Exception {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] chunk = new byte[4096];
int read;
while ((read = inputStream.read(chunk)) >= 0) {
buffer.write(chunk, 0, read);
}
String payload = new String(buffer.toByteArray(), StandardCharsets.UTF_8);
Object parsed = MiniJson.parse(payload);
if (!(parsed instanceof Map<?, ?>)) {
throw new IllegalArgumentException("helper request must be a JSON object");
}
return (Map<String, Object>) parsed;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,316 @@
package com.gonavi.jmxhelper;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
final class MiniJson {
private MiniJson() {
}
static Object parse(String text) {
Parser parser = new Parser(text);
Object value = parser.parseValue();
parser.skipWhitespace();
if (!parser.isDone()) {
throw new IllegalArgumentException("JSON parsing did not consume the full payload");
}
return value;
}
static String stringify(Object value) {
StringBuilder builder = new StringBuilder();
writeValue(builder, value);
return builder.toString();
}
private static void writeValue(StringBuilder builder, Object value) {
if (value == null) {
builder.append("null");
return;
}
if (value instanceof String) {
writeString(builder, (String) value);
return;
}
if (value instanceof Number || value instanceof Boolean) {
builder.append(String.valueOf(value));
return;
}
if (value instanceof Map<?, ?>) {
builder.append('{');
Iterator<? extends Map.Entry<?, ?>> iterator = ((Map<?, ?>) value).entrySet().iterator();
boolean first = true;
while (iterator.hasNext()) {
Map.Entry<?, ?> entry = iterator.next();
if (!first) {
builder.append(',');
}
first = false;
writeString(builder, String.valueOf(entry.getKey()));
builder.append(':');
writeValue(builder, entry.getValue());
}
builder.append('}');
return;
}
if (value instanceof Iterable<?>) {
builder.append('[');
Iterator<?> iterator = ((Iterable<?>) value).iterator();
boolean first = true;
while (iterator.hasNext()) {
if (!first) {
builder.append(',');
}
first = false;
writeValue(builder, iterator.next());
}
builder.append(']');
return;
}
if (value.getClass().isArray()) {
builder.append('[');
int length = java.lang.reflect.Array.getLength(value);
for (int index = 0; index < length; index++) {
if (index > 0) {
builder.append(',');
}
writeValue(builder, java.lang.reflect.Array.get(value, index));
}
builder.append(']');
return;
}
writeString(builder, String.valueOf(value));
}
private static void writeString(StringBuilder builder, String value) {
builder.append('"');
for (int index = 0; index < value.length(); index++) {
char ch = value.charAt(index);
switch (ch) {
case '"':
builder.append("\\\"");
break;
case '\\':
builder.append("\\\\");
break;
case '\b':
builder.append("\\b");
break;
case '\f':
builder.append("\\f");
break;
case '\n':
builder.append("\\n");
break;
case '\r':
builder.append("\\r");
break;
case '\t':
builder.append("\\t");
break;
default:
if (ch < 0x20) {
builder.append(String.format("\\u%04x", (int) ch));
} else {
builder.append(ch);
}
break;
}
}
builder.append('"');
}
private static final class Parser {
private final String text;
private int index;
private Parser(String text) {
this.text = text == null ? "" : text;
this.index = 0;
}
private Object parseValue() {
skipWhitespace();
if (isDone()) {
throw new IllegalArgumentException("unexpected end of JSON input");
}
char ch = text.charAt(index);
switch (ch) {
case '{':
return parseObject();
case '[':
return parseArray();
case '"':
return parseString();
case 't':
expect("true");
return Boolean.TRUE;
case 'f':
expect("false");
return Boolean.FALSE;
case 'n':
expect("null");
return null;
default:
if (ch == '-' || Character.isDigit(ch)) {
return parseNumber();
}
throw new IllegalArgumentException("unexpected JSON token at index " + index);
}
}
private Map<String, Object> parseObject() {
expect("{");
LinkedHashMap<String, Object> result = new LinkedHashMap<>();
skipWhitespace();
if (peek('}')) {
expect("}");
return result;
}
while (true) {
String key = parseString();
skipWhitespace();
expect(":");
Object value = parseValue();
result.put(key, value);
skipWhitespace();
if (peek('}')) {
expect("}");
return result;
}
expect(",");
}
}
private List<Object> parseArray() {
expect("[");
ArrayList<Object> result = new ArrayList<>();
skipWhitespace();
if (peek(']')) {
expect("]");
return result;
}
while (true) {
result.add(parseValue());
skipWhitespace();
if (peek(']')) {
expect("]");
return result;
}
expect(",");
}
}
private String parseString() {
expect("\"");
StringBuilder builder = new StringBuilder();
while (!isDone()) {
char ch = text.charAt(index++);
if (ch == '"') {
return builder.toString();
}
if (ch != '\\') {
builder.append(ch);
continue;
}
if (isDone()) {
throw new IllegalArgumentException("unterminated escape sequence");
}
char escaped = text.charAt(index++);
switch (escaped) {
case '"':
case '\\':
case '/':
builder.append(escaped);
break;
case 'b':
builder.append('\b');
break;
case 'f':
builder.append('\f');
break;
case 'n':
builder.append('\n');
break;
case 'r':
builder.append('\r');
break;
case 't':
builder.append('\t');
break;
case 'u':
if (index + 4 > text.length()) {
throw new IllegalArgumentException("invalid unicode escape");
}
builder.append((char) Integer.parseInt(text.substring(index, index + 4), 16));
index += 4;
break;
default:
throw new IllegalArgumentException("unsupported escape sequence: \\" + escaped);
}
}
throw new IllegalArgumentException("unterminated JSON string");
}
private Number parseNumber() {
int start = index;
if (text.charAt(index) == '-') {
index++;
}
while (!isDone() && Character.isDigit(text.charAt(index))) {
index++;
}
boolean isFloat = false;
if (!isDone() && text.charAt(index) == '.') {
isFloat = true;
index++;
while (!isDone() && Character.isDigit(text.charAt(index))) {
index++;
}
}
if (!isDone() && (text.charAt(index) == 'e' || text.charAt(index) == 'E')) {
isFloat = true;
index++;
if (!isDone() && (text.charAt(index) == '+' || text.charAt(index) == '-')) {
index++;
}
while (!isDone() && Character.isDigit(text.charAt(index))) {
index++;
}
}
String raw = text.substring(start, index);
return isFloat ? Double.valueOf(raw) : Long.valueOf(raw);
}
private void expect(String expected) {
skipWhitespace();
if (!text.startsWith(expected, index)) {
throw new IllegalArgumentException("expected " + expected + " at index " + index);
}
index += expected.length();
}
private boolean peek(char ch) {
skipWhitespace();
return !isDone() && text.charAt(index) == ch;
}
private void skipWhitespace() {
while (!isDone()) {
char ch = text.charAt(index);
if (!Character.isWhitespace(ch)) {
return;
}
index++;
}
}
private boolean isDone() {
return index >= text.length();
}
}
}