mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-06 20:03:05 +08:00
✨ feat(jvm): 完成资源治理与诊断增强
- 新增 JMX/Endpoint/Agent 三种 JVM 连接模式与配置归一化链路 - 支持资源浏览、变更预览、写入应用、审计记录与只读约束 - 接入 AI 结构化写入计划与诊断计划回填能力 - 新增 Agent Bridge、Arthas Tunnel、JMX Helper 诊断传输实现 - 增加诊断控制台、命令模板、输出历史与自动补全交互 - 补齐前后端契约、运行夹具与 JVM 相关回归测试
This commit is contained in:
@@ -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 首期支持分析与生成修改计划,不默认开放自动执行
|
||||
- 所有修改必须经过预览、确认、审计和回读验证
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
- 决策 6:AI 计划应用入口只回填 JVM 预览草稿,后续仍必须经过 `JVMPreviewChange`、Guard 校验和人工确认
|
||||
- 决策 7:当前 MVP 中 `updateValue` 会映射到现有 JVM 变更 contract 的 `put`,且 payload 仅接受 JSON 对象
|
||||
- 决策 8:JVM AI 计划必须绑定生成时的 JVM 上下文,只允许投递到匹配的 `tabId + connectionId + providerMode + resourcePath`
|
||||
- 决策 9:JMX helper 采用 Java 8 兼容的预编译 runtime jar 内嵌分发,运行时只依赖本地 `java`
|
||||
- 决策 10:Agent 模式按“预埋 GoNavi Java Agent + 独立 Agent Base URL 接入”落地,不在当前版本实现动态 attach
|
||||
|
||||
## 7. 验证记录
|
||||
- 验证项:
|
||||
@@ -96,6 +111,9 @@
|
||||
- Task 1:JVM 共享契约与配置归一化
|
||||
- Task 2:Provider 注册、连接测试与能力探测 API
|
||||
- Task 6:AI 计划解析、资源定位解析、契约映射与页签上下文隔离
|
||||
- Task 7:Java Endpoint fixture 真实集成验证
|
||||
- Task 7:JMX helper 内嵌分发与运行时缓存验证
|
||||
- Task 7:Agent provider 与真实 Java Agent 集成验证
|
||||
- Task 7:后端全量测试
|
||||
- Task 7:前端全量测试
|
||||
- Task 7:前端生产构建
|
||||
@@ -117,11 +135,24 @@
|
||||
- 前端已完成 JVM 变更草稿区、预览弹窗、执行确认、审计记录页签与按 provider mode 的审计过滤
|
||||
- 已完成 Task 6:AI 计划解析、资源定位解析、`updateValue -> put` 显式映射、JSON 对象 payload 约束和上下文绑定单测
|
||||
- 已完成 Task 6:AI 聊天消息与 JVM 来源页签绑定,AI 气泡应用按钮不再依赖点击时的 `activeTabId`,避免跨 JVM 页签误投递
|
||||
- `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts` 通过(10 tests)
|
||||
- 已完成 Task 7:Java Endpoint fixture,可真实验证 `resources / value / preview / apply` 四个 endpoint contract
|
||||
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` 通过
|
||||
- 已完成 Task 7:JMX helper 改为预编译 jar 内嵌分发,并补齐 classpath 覆盖与缓存落盘单测
|
||||
- `go test ./internal/jvm -run 'TestEnsureJMXHelperRuntime|TestJMXProvider' -count=1` 通过
|
||||
- 已完成 Task 7:Agent 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 files,258 tests)
|
||||
- `cd frontend && npm test -- --run` 通过(61 files,259 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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
60
frontend/src/components/JVMDiagnosticConsole.test.tsx
Normal file
60
frontend/src/components/JVMDiagnosticConsole.test.tsx
Normal 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"');
|
||||
});
|
||||
});
|
||||
529
frontend/src/components/JVMDiagnosticConsole.tsx
Normal file
529
frontend/src/components/JVMDiagnosticConsole.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
114
frontend/src/components/JVMResourceBrowser.layout.test.tsx
Normal file
114
frontend/src/components/JVMResourceBrowser.layout.test.tsx
Normal 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)');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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'] = [
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
52
frontend/src/components/jvm/JVMCommandPresetBar.tsx
Normal file
52
frontend/src/components/jvm/JVMCommandPresetBar.tsx
Normal 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;
|
||||
78
frontend/src/components/jvm/JVMDiagnosticHistory.tsx
Normal file
78
frontend/src/components/jvm/JVMDiagnosticHistory.tsx
Normal 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;
|
||||
50
frontend/src/components/jvm/JVMDiagnosticOutput.tsx
Normal file
50
frontend/src/components/jvm/JVMDiagnosticOutput.tsx
Normal 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
@@ -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;
|
||||
|
||||
@@ -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 远程管理端口',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
47
frontend/src/utils/jvmDiagnosticCompletion.test.ts
Normal file
47
frontend/src/utils/jvmDiagnosticCompletion.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
485
frontend/src/utils/jvmDiagnosticCompletion.ts
Normal file
485
frontend/src/utils/jvmDiagnosticCompletion.ts
Normal 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));
|
||||
};
|
||||
119
frontend/src/utils/jvmDiagnosticPlan.test.ts
Normal file
119
frontend/src/utils/jvmDiagnosticPlan.test.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
135
frontend/src/utils/jvmDiagnosticPlan.ts
Normal file
135
frontend/src/utils/jvmDiagnosticPlan.ts
Normal 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 || "";
|
||||
};
|
||||
35
frontend/src/utils/jvmDiagnosticPresentation.test.ts
Normal file
35
frontend/src/utils/jvmDiagnosticPresentation.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
105
frontend/src/utils/jvmDiagnosticPresentation.ts
Normal file
105
frontend/src/utils/jvmDiagnosticPresentation.ts
Normal 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}`;
|
||||
};
|
||||
77
frontend/src/utils/jvmResourcePresentation.test.ts
Normal file
77
frontend/src/utils/jvmResourcePresentation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
238
frontend/src/utils/jvmResourcePresentation.ts
Normal file
238
frontend/src/utils/jvmResourcePresentation.ts
Normal 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 };
|
||||
@@ -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();
|
||||
|
||||
10
frontend/wailsjs/go/app/App.d.ts
vendored
10
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
2
go.mod
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
294
internal/app/methods_jvm_diagnostic.go
Normal file
294
internal/app/methods_jvm_diagnostic.go
Normal 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, ";")
|
||||
}
|
||||
255
internal/app/methods_jvm_diagnostic_test.go
Normal file
255
internal/app/methods_jvm_diagnostic_test.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 等网络层设置。
|
||||
|
||||
139
internal/jvm/agent_provider.go
Normal file
139
internal/jvm/agent_provider.go
Normal 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
|
||||
}
|
||||
305
internal/jvm/agent_provider_test.go
Normal file
305
internal/jvm/agent_provider_test.go
Normal 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
206
internal/jvm/connection_error_message.go
Normal file
206
internal/jvm/connection_error_message.go
Normal 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")
|
||||
}
|
||||
237
internal/jvm/diagnostic_agent_bridge.go
Normal file
237
internal/jvm/diagnostic_agent_bridge.go
Normal 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
|
||||
}
|
||||
134
internal/jvm/diagnostic_agent_bridge_test.go
Normal file
134
internal/jvm/diagnostic_agent_bridge_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
591
internal/jvm/diagnostic_arthas_tunnel.go
Normal file
591
internal/jvm/diagnostic_arthas_tunnel.go
Normal 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()
|
||||
}
|
||||
}
|
||||
376
internal/jvm/diagnostic_arthas_tunnel_test.go
Normal file
376
internal/jvm/diagnostic_arthas_tunnel_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
86
internal/jvm/diagnostic_audit_store.go
Normal file
86
internal/jvm/diagnostic_audit_store.go
Normal 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
|
||||
}
|
||||
37
internal/jvm/diagnostic_audit_store_test.go
Normal file
37
internal/jvm/diagnostic_audit_store_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
109
internal/jvm/diagnostic_config.go
Normal file
109
internal/jvm/diagnostic_config.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
53
internal/jvm/diagnostic_config_test.go
Normal file
53
internal/jvm/diagnostic_config_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
71
internal/jvm/diagnostic_transport.go
Normal file
71
internal/jvm/diagnostic_transport.go
Normal 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)
|
||||
}
|
||||
66
internal/jvm/diagnostic_types.go
Normal file
66
internal/jvm/diagnostic_types.go
Normal 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"`
|
||||
}
|
||||
195
internal/jvm/http_contract.go
Normal file
195
internal/jvm/http_contract.go
Normal 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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
540
internal/jvm/http_provider_test.go
Normal file
540
internal/jvm/http_provider_test.go
Normal 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
756
internal/jvm/jmx_helper.go
Normal 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
|
||||
}
|
||||
84
internal/jvm/jmx_helper_test.go
Normal file
84
internal/jvm/jmx_helper_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
574
internal/jvm/jmx_provider_test.go
Normal file
574
internal/jvm/jmx_provider_test.go
Normal 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
|
||||
}
|
||||
15
internal/jvm/jmxhelper_assets/README.md
Normal file
15
internal/jvm/jmxhelper_assets/README.md
Normal 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"
|
||||
```
|
||||
BIN
internal/jvm/jmxhelper_assets/jmx-helper-runtime.jar
Normal file
BIN
internal/jvm/jmxhelper_assets/jmx-helper-runtime.jar
Normal file
Binary file not shown.
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
12
internal/jvm/testdata/agentfixture/src/com/gonavi/fixture/AgentHostApp.java
vendored
Normal file
12
internal/jvm/testdata/agentfixture/src/com/gonavi/fixture/AgentHostApp.java
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
384
internal/jvm/testdata/agentfixture/src/com/gonavi/fixture/GoNaviTestAgent.java
vendored
Normal file
384
internal/jvm/testdata/agentfixture/src/com/gonavi/fixture/GoNaviTestAgent.java
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
323
internal/jvm/testdata/agentfixture/src/com/gonavi/fixture/MiniJson.java
vendored
Normal file
323
internal/jvm/testdata/agentfixture/src/com/gonavi/fixture/MiniJson.java
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
358
internal/jvm/testdata/endpointfixture/src/com/gonavi/fixture/EndpointTestServer.java
vendored
Normal file
358
internal/jvm/testdata/endpointfixture/src/com/gonavi/fixture/EndpointTestServer.java
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
311
internal/jvm/testdata/endpointfixture/src/com/gonavi/fixture/MiniJson.java
vendored
Normal file
311
internal/jvm/testdata/endpointfixture/src/com/gonavi/fixture/MiniJson.java
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
34
internal/jvm/testdata/jmxfixture/src/com/gonavi/fixture/CacheSettings.java
vendored
Normal file
34
internal/jvm/testdata/jmxfixture/src/com/gonavi/fixture/CacheSettings.java
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
12
internal/jvm/testdata/jmxfixture/src/com/gonavi/fixture/CacheSettingsMBean.java
vendored
Normal file
12
internal/jvm/testdata/jmxfixture/src/com/gonavi/fixture/CacheSettingsMBean.java
vendored
Normal 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);
|
||||
}
|
||||
24
internal/jvm/testdata/jmxfixture/src/com/gonavi/fixture/JMXTestServer.java
vendored
Normal file
24
internal/jvm/testdata/jmxfixture/src/com/gonavi/fixture/JMXTestServer.java
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
46
tools/jmx-helper/src/com/gonavi/jmxhelper/JmxHelperMain.java
Normal file
46
tools/jmx-helper/src/com/gonavi/jmxhelper/JmxHelperMain.java
Normal 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;
|
||||
}
|
||||
}
|
||||
1052
tools/jmx-helper/src/com/gonavi/jmxhelper/JmxRuntime.java
Normal file
1052
tools/jmx-helper/src/com/gonavi/jmxhelper/JmxRuntime.java
Normal file
File diff suppressed because it is too large
Load Diff
316
tools/jmx-helper/src/com/gonavi/jmxhelper/MiniJson.java
Normal file
316
tools/jmx-helper/src/com/gonavi/jmxhelper/MiniJson.java
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user