diff --git a/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md b/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md index d578e78..09213a8 100644 --- a/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md +++ b/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md @@ -26,7 +26,7 @@ - [x] 阶段 2(影响分析):完成 - [x] 阶段 3(方案设计):完成(已形成正式设计文档) - [x] 阶段 4(实施计划):完成(已形成正式实施计划) -- [ ] 阶段 5(实现与自检):进行中(Task 1 已完成并通过回归) +- [ ] 阶段 5(实现与自检):进行中(Task 1、Task 2 已完成并通过回归) - [ ] 阶段 6(评审与交付): - [ ] 阶段 7(发布与观察): @@ -40,11 +40,12 @@ - 用户明确目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach - 已形成 JVM 缓存可视化编辑正式设计文档 - 已形成 JVM Connector MVP 正式实施计划文档 -- - 已完成 Task 1:JVM 共享契约与配置归一化 + - 已完成 Task 1:JVM 共享契约与配置归一化 + - 已完成 Task 2:Provider 注册、连接测试与能力探测 API - 进行中: - - Task 2:建立后端 Provider 注册与连接探测 API + - Task 3:接入 JVM 连接表单与图标 - 待处理: - - Task 3+:Guard/Audit/App/UI/AI 结构化计划等后续任务 + - Task 4+:只读资源浏览、Guard/Audit、AI 结构化计划等后续任务 ## 5. 风险与阻塞 - 风险: @@ -73,12 +74,15 @@ - JVM Connector 正式设计文档自检 - JVM Connector 实施计划文档自检 - Task 1:JVM 共享契约与配置归一化 + - Task 2:Provider 注册、连接测试与能力探测 API - 结果: - 已确认存在可复用的连接桥接与编辑器基础设施 - 已完成正式设计文档落盘与自检,未发现占位词和明显范围冲突 - 已完成正式实施计划落盘与自检,已补齐共享 DTO、provider factory 和审计落盘等关键实现细节 - 已完成 JVM 连接共享契约、默认只读/默认 JMX 归一化、前端配置收敛与补测 - Task 1 已完成规格审查与代码质量审查,结论均通过 + - 已完成 JVM Provider 工厂、JMX/Endpoint provider 骨架、App 层连接测试与能力探测 API + - Task 2 已完成规格审查与代码质量审查,结论均通过 - 证据(日志/截图/链接): - `cmd/optional-driver-agent/main.go` - `internal/db/database.go` @@ -99,7 +103,19 @@ - `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts` - `cd frontend && npm test -- --run` - `cd frontend && npm run build` + - `internal/jvm/provider.go` + - `internal/jvm/jmx_provider.go` + - `internal/jvm/http_provider.go` + - `internal/jvm/provider_contract_test.go` + - `internal/app/methods_jvm.go` + - `internal/app/methods_jvm_test.go` + - `frontend/wailsjs/go/app/App.d.ts` + - `frontend/wailsjs/go/app/App.js` + - `frontend/wailsjs/go/models.ts` + - `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1` + - `go test ./internal/jvm ./internal/app -count=1` + - `wails build -clean` ## 8. 下一步 -- 下一步行动:进入 Task 2,建立 JVM Provider 注册、连接测试与能力探测 API,并在完成后生成/校验 Wails 绑定代码 +- 下一步行动:进入 Task 3,接入 JVM 连接表单、图标与展示文案,并在前端完成最小交互闭环 - 负责人:Codex diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 30f7e6b..1fd4fc9 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -127,6 +127,10 @@ export function InstallLocalDriverPackage(arg1:string,arg2:string,arg3:string,ar export function InstallUpdateAndRestart():Promise; +export function JVMProbeCapabilities(arg1:connection.ConnectionConfig):Promise; + +export function ListSQLDirectory(arg1:string):Promise; + export function LogWindowDiagnostic(arg1:string,arg2:string):Promise; export function MongoDiscoverMembers(arg1:connection.ConnectionConfig):Promise; @@ -149,8 +153,6 @@ export function OpenDriverDownloadDirectory(arg1:string):Promise; -export function ListSQLDirectory(arg1:string):Promise; - export function PreviewImportFile(arg1:string):Promise; export function ReadSQLFile(arg1:string):Promise; @@ -223,8 +225,6 @@ export function RetrySecurityUpdateCurrentRound(arg1:app.RetrySecurityUpdateRequ export function SaveConnection(arg1:connection.SavedConnectionInput):Promise; -export function SelectSQLDirectory(arg1:string):Promise; - export function SaveGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise; export function SelectDataRootDirectory(arg1:string):Promise; @@ -237,6 +237,8 @@ export function SelectDriverPackageDirectory(arg1:string):Promise; +export function SelectSQLDirectory(arg1:string):Promise; + export function SelectSSHKeyFile(arg1:string):Promise; export function SetMacNativeWindowControls(arg1:boolean):Promise; @@ -247,4 +249,6 @@ export function StartSecurityUpdate(arg1:app.StartSecurityUpdateRequest):Promise export function TestConnection(arg1:connection.ConnectionConfig):Promise; +export function TestJVMConnection(arg1:connection.ConnectionConfig):Promise; + export function TruncateTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index e610187..f461f22 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -246,6 +246,14 @@ export function InstallUpdateAndRestart() { return window['go']['app']['App']['InstallUpdateAndRestart'](); } +export function JVMProbeCapabilities(arg1) { + return window['go']['app']['App']['JVMProbeCapabilities'](arg1); +} + +export function ListSQLDirectory(arg1) { + return window['go']['app']['App']['ListSQLDirectory'](arg1); +} + export function LogWindowDiagnostic(arg1, arg2) { return window['go']['app']['App']['LogWindowDiagnostic'](arg1, arg2); } @@ -290,10 +298,6 @@ export function OpenSQLFile() { return window['go']['app']['App']['OpenSQLFile'](); } -export function ListSQLDirectory(arg1) { - return window['go']['app']['App']['ListSQLDirectory'](arg1); -} - export function PreviewImportFile(arg1) { return window['go']['app']['App']['PreviewImportFile'](arg1); } @@ -438,10 +442,6 @@ export function SaveConnection(arg1) { return window['go']['app']['App']['SaveConnection'](arg1); } -export function SelectSQLDirectory(arg1) { - return window['go']['app']['App']['SelectSQLDirectory'](arg1); -} - export function SaveGlobalProxy(arg1) { return window['go']['app']['App']['SaveGlobalProxy'](arg1); } @@ -466,6 +466,10 @@ export function SelectDriverPackageFile(arg1) { return window['go']['app']['App']['SelectDriverPackageFile'](arg1); } +export function SelectSQLDirectory(arg1) { + return window['go']['app']['App']['SelectSQLDirectory'](arg1); +} + export function SelectSSHKeyFile(arg1) { return window['go']['app']['App']['SelectSSHKeyFile'](arg1); } @@ -486,6 +490,10 @@ export function TestConnection(arg1) { return window['go']['app']['App']['TestConnection'](arg1); } +export function TestJVMConnection(arg1) { + return window['go']['app']['App']['TestJVMConnection'](arg1); +} + export function TruncateTables(arg1, arg2, arg3) { return window['go']['app']['App']['TruncateTables'](arg1, arg2, arg3); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index bd26995..eea5834 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -456,6 +456,86 @@ export namespace connection { return a; } } + export class JVMEndpointConfig { + enabled?: boolean; + baseUrl?: string; + apiKey?: string; + timeoutSeconds?: number; + + static createFrom(source: any = {}) { + return new JVMEndpointConfig(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 JVMJMXConfig { + enabled?: boolean; + host?: string; + port?: number; + username?: string; + password?: string; + domainAllowlist?: string[]; + + static createFrom(source: any = {}) { + return new JVMJMXConfig(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.enabled = source["enabled"]; + this.host = source["host"]; + this.port = source["port"]; + this.username = source["username"]; + this.password = source["password"]; + this.domainAllowlist = source["domainAllowlist"]; + } + } + export class JVMConfig { + environment?: string; + readOnly?: boolean; + allowedModes?: string[]; + preferredMode?: string; + jmx?: JVMJMXConfig; + endpoint?: JVMEndpointConfig; + + static createFrom(source: any = {}) { + return new JVMConfig(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.environment = source["environment"]; + this.readOnly = source["readOnly"]; + this.allowedModes = source["allowedModes"]; + this.preferredMode = source["preferredMode"]; + this.jmx = this.convertValues(source["jmx"], JVMJMXConfig); + this.endpoint = this.convertValues(source["endpoint"], JVMEndpointConfig); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } export class HTTPTunnelConfig { host: string; port: number; @@ -549,6 +629,7 @@ export namespace connection { mongoAuthMechanism?: string; mongoReplicaUser?: string; mongoReplicaPassword?: string; + jvm?: JVMConfig; static createFrom(source: any = {}) { return new ConnectionConfig(source); @@ -590,6 +671,7 @@ export namespace connection { this.mongoAuthMechanism = source["mongoAuthMechanism"]; this.mongoReplicaUser = source["mongoReplicaUser"]; this.mongoReplicaPassword = source["mongoReplicaPassword"]; + this.jvm = this.convertValues(source["jvm"], JVMConfig); } convertValues(a: any, classs: any, asMap: boolean = false): any { @@ -638,6 +720,9 @@ export namespace connection { } + + + export class QueryResult { success: boolean; message: string; diff --git a/internal/app/methods_jvm.go b/internal/app/methods_jvm.go new file mode 100644 index 0000000..89d329e --- /dev/null +++ b/internal/app/methods_jvm.go @@ -0,0 +1,60 @@ +package app + +import ( + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/jvm" +) + +var newJVMProvider = jvm.NewProvider + +func (a *App) TestJVMConnection(cfg connection.ConnectionConfig) connection.QueryResult { + normalized, err := jvm.NormalizeConnectionConfig(cfg) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + provider, err := newJVMProvider(normalized.JVM.PreferredMode) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + if err := provider.TestConnection(a.ctx, normalized); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + return connection.QueryResult{Success: true, Message: "JVM 连接成功"} +} + +func (a *App) JVMProbeCapabilities(cfg connection.ConnectionConfig) connection.QueryResult { + normalized, err := jvm.NormalizeConnectionConfig(cfg) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + items := make([]jvm.Capability, 0, len(normalized.JVM.AllowedModes)) + for _, mode := range normalized.JVM.AllowedModes { + provider, providerErr := newJVMProvider(mode) + if providerErr != nil { + items = append(items, jvm.Capability{ + Mode: mode, + DisplayLabel: jvm.ModeDisplayLabel(mode), + Reason: providerErr.Error(), + }) + continue + } + + caps, probeErr := provider.ProbeCapabilities(a.ctx, normalized) + if probeErr != nil { + items = append(items, jvm.Capability{ + Mode: mode, + DisplayLabel: jvm.ModeDisplayLabel(mode), + Reason: probeErr.Error(), + }) + continue + } + + items = append(items, caps...) + } + + return connection.QueryResult{Success: true, Data: items} +} diff --git a/internal/app/methods_jvm_test.go b/internal/app/methods_jvm_test.go new file mode 100644 index 0000000..3bd20cd --- /dev/null +++ b/internal/app/methods_jvm_test.go @@ -0,0 +1,240 @@ +package app + +import ( + "context" + "errors" + "strings" + "testing" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/jvm" +) + +type fakeJVMProvider struct { + testErr error + probe []jvm.Capability + probeErr error + list []jvm.ResourceSummary + value jvm.ValueSnapshot + apply jvm.ApplyResult +} + +func (f fakeJVMProvider) Mode() string { return jvm.ModeJMX } +func (f fakeJVMProvider) TestConnection(context.Context, connection.ConnectionConfig) error { + return f.testErr +} +func (f fakeJVMProvider) ProbeCapabilities(context.Context, connection.ConnectionConfig) ([]jvm.Capability, error) { + return f.probe, f.probeErr +} +func (f fakeJVMProvider) ListResources(context.Context, connection.ConnectionConfig, string) ([]jvm.ResourceSummary, error) { + return f.list, nil +} +func (f fakeJVMProvider) GetValue(context.Context, connection.ConnectionConfig, string) (jvm.ValueSnapshot, error) { + return f.value, nil +} +func (f fakeJVMProvider) PreviewChange(context.Context, connection.ConnectionConfig, jvm.ChangeRequest) (jvm.ChangePreview, error) { + return jvm.ChangePreview{Allowed: true, Summary: "preview"}, nil +} +func (f fakeJVMProvider) ApplyChange(context.Context, connection.ConnectionConfig, jvm.ChangeRequest) (jvm.ApplyResult, error) { + return f.apply, nil +} + +func swapJVMProviderFactory(factory func(mode string) (jvm.Provider, error)) func() { + prev := newJVMProvider + newJVMProvider = factory + return func() { newJVMProvider = prev } +} + +func TestTestJVMConnectionUsesPreferredProvider(t *testing.T) { + app := NewAppWithSecretStore(nil) + var gotMode string + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + gotMode = mode + return fakeJVMProvider{}, nil + }) + defer restore() + + res := app.TestJVMConnection(connection.ConnectionConfig{ + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: "endpoint", + AllowedModes: []string{"jmx", "endpoint"}, + }, + }) + + if !res.Success { + t.Fatalf("expected success, got %+v", res) + } + if gotMode != "endpoint" { + t.Fatalf("expected provider mode endpoint, got %q", gotMode) + } + if res.Message != "JVM 连接成功" { + t.Fatalf("expected success message %q, got %q", "JVM 连接成功", res.Message) + } +} + +func TestTestJVMConnectionReturnsProviderError(t *testing.T) { + app := NewAppWithSecretStore(nil) + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{testErr: errors.New("dial failed")}, nil + }) + defer restore() + + res := app.TestJVMConnection(connection.ConnectionConfig{ + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + }) + + if res.Success { + t.Fatalf("expected failure, got %+v", res) + } + if res.Message != "dial failed" { + t.Fatalf("expected message %q, got %q", "dial failed", res.Message) + } +} + +func TestTestJVMConnectionReturnsProviderFactoryError(t *testing.T) { + app := NewAppWithSecretStore(nil) + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return nil, errors.New("factory unavailable") + }) + defer restore() + + res := app.TestJVMConnection(connection.ConnectionConfig{ + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: "endpoint", + AllowedModes: []string{"endpoint"}, + }, + }) + + if res.Success { + t.Fatalf("expected failure, got %+v", res) + } + if res.Message != "factory unavailable" { + t.Fatalf("expected message %q, got %q", "factory unavailable", res.Message) + } +} + +func TestJVMProbeCapabilitiesReturnsCapabilityArray(t *testing.T) { + app := NewAppWithSecretStore(nil) + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + probe: []jvm.Capability{{Mode: jvm.ModeJMX, CanBrowse: true, CanWrite: false, CanPreview: false, DisplayLabel: "JMX"}}, + }, nil + }) + defer restore() + + res := app.JVMProbeCapabilities(connection.ConnectionConfig{ + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: "jmx", + 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) + } +} + +func TestJVMProbeCapabilitiesIncludesReasonWhenProbeFails(t *testing.T) { + app := NewAppWithSecretStore(nil) + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + probeErr: errors.New("probe failed"), + }, nil + }) + defer restore() + + res := app.JVMProbeCapabilities(connection.ConnectionConfig{ + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: "jmx", + 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 items[0].Reason != "probe failed" { + t.Fatalf("expected reason %q, got %#v", "probe failed", items[0]) + } +} + +func TestJVMProbeCapabilitiesIncludesReasonWhenProviderFactoryFails(t *testing.T) { + app := NewAppWithSecretStore(nil) + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return nil, errors.New("provider disabled") + }) + 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].Reason != "provider disabled" { + t.Fatalf("expected reason %q, got %#v", "provider disabled", items[0]) + } + if items[0].DisplayLabel != "Endpoint" { + t.Fatalf("expected display label %q, got %#v", "Endpoint", items[0]) + } +} + +func TestJVMProbeCapabilitiesUsesReadableLabelForUnsupportedMode(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: "agent", + AllowedModes: []string{"agent"}, + }, + }) + + 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 != "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]) + } +} diff --git a/internal/jvm/http_provider.go b/internal/jvm/http_provider.go new file mode 100644 index 0000000..a629ab5 --- /dev/null +++ b/internal/jvm/http_provider.go @@ -0,0 +1,93 @@ +package jvm + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "GoNavi-Wails/internal/connection" +) + +type HTTPProvider struct{} + +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) + } + + 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) + 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) + if err != nil { + return err + } + } + defer resp.Body.Close() + if isReachableStatus(resp.StatusCode) { + return nil + } + 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) ListResources(ctx context.Context, cfg connection.ConnectionConfig, parentPath string) ([]ResourceSummary, error) { + return nil, errProviderNotImplemented(p.Mode(), "list resources") +} + +func (p *HTTPProvider) GetValue(ctx context.Context, cfg connection.ConnectionConfig, resourcePath string) (ValueSnapshot, error) { + return ValueSnapshot{}, errProviderNotImplemented(p.Mode(), "get value") +} + +func (p *HTTPProvider) PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) { + return ChangePreview{}, errProviderNotImplemented(p.Mode(), "preview change") +} + +func (p *HTTPProvider) ApplyChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ApplyResult, error) { + return ApplyResult{}, errProviderNotImplemented(p.Mode(), "apply change") +} + +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 +} + +func isReachableStatus(statusCode int) bool { + return (statusCode >= 200 && statusCode < 400) || statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden +} diff --git a/internal/jvm/jmx_provider.go b/internal/jvm/jmx_provider.go new file mode 100644 index 0000000..d9c6064 --- /dev/null +++ b/internal/jvm/jmx_provider.go @@ -0,0 +1,64 @@ +package jvm + +import ( + "context" + "fmt" + "net" + "strconv" + "strings" + "time" + + "GoNavi-Wails/internal/connection" +) + +type JMXProvider struct{} + +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 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))) + if err != nil { + return fmt.Errorf("jmx tcp connect 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 +} + +func (p *JMXProvider) ListResources(ctx context.Context, cfg connection.ConnectionConfig, parentPath string) ([]ResourceSummary, error) { + return nil, errProviderNotImplemented(p.Mode(), "list resources") +} + +func (p *JMXProvider) GetValue(ctx context.Context, cfg connection.ConnectionConfig, resourcePath string) (ValueSnapshot, error) { + return ValueSnapshot{}, errProviderNotImplemented(p.Mode(), "get value") +} + +func (p *JMXProvider) PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) { + return ChangePreview{}, errProviderNotImplemented(p.Mode(), "preview change") +} + +func (p *JMXProvider) ApplyChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ApplyResult, error) { + return ApplyResult{}, errProviderNotImplemented(p.Mode(), "apply change") +} diff --git a/internal/jvm/provider.go b/internal/jvm/provider.go new file mode 100644 index 0000000..1fea1fe --- /dev/null +++ b/internal/jvm/provider.go @@ -0,0 +1,57 @@ +package jvm + +import ( + "context" + "fmt" + "strings" + + "GoNavi-Wails/internal/connection" +) + +type Provider interface { + Mode() string + TestConnection(ctx context.Context, cfg connection.ConnectionConfig) error + ProbeCapabilities(ctx context.Context, cfg connection.ConnectionConfig) ([]Capability, error) + ListResources(ctx context.Context, cfg connection.ConnectionConfig, parentPath string) ([]ResourceSummary, error) + GetValue(ctx context.Context, cfg connection.ConnectionConfig, resourcePath string) (ValueSnapshot, error) + PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) + ApplyChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ApplyResult, error) +} + +var providerFactories = map[string]func() Provider{ + ModeJMX: func() Provider { return NewJMXProvider() }, + ModeEndpoint: func() Provider { return NewHTTPProvider() }, +} + +func NewProvider(mode string) (Provider, error) { + normalized := strings.ToLower(strings.TrimSpace(mode)) + factory, ok := providerFactories[normalized] + if !ok { + return nil, fmt.Errorf("unsupported jvm provider mode: %s", mode) + } + return factory(), nil +} + +func ModeDisplayLabel(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case ModeJMX: + return "JMX" + case ModeEndpoint: + return "Endpoint" + case ModeAgent: + return "Agent" + default: + normalized := strings.TrimSpace(mode) + if normalized == "" { + return "Unknown" + } + if len(normalized) == 1 { + return strings.ToUpper(normalized) + } + return strings.ToUpper(normalized[:1]) + strings.ToLower(normalized[1:]) + } +} + +func errProviderNotImplemented(mode string, action string) error { + return fmt.Errorf("%s provider does not implement %s yet", ModeDisplayLabel(mode), action) +} diff --git a/internal/jvm/provider_contract_test.go b/internal/jvm/provider_contract_test.go new file mode 100644 index 0000000..56bc0cb --- /dev/null +++ b/internal/jvm/provider_contract_test.go @@ -0,0 +1,102 @@ +package jvm + +import ( + "context" + "strings" + "testing" + + "GoNavi-Wails/internal/connection" +) + +func TestJMXProviderTestConnectionReturnsErrorWhenHostMissing(t *testing.T) { + provider := NewJMXProvider() + + err := provider.TestConnection(context.Background(), connection.ConnectionConfig{ + Type: "jvm", + JVM: connection.JVMConfig{ + JMX: connection.JVMJMXConfig{ + Port: 9010, + }, + }, + }) + + if err == nil { + t.Fatal("expected error when jmx host is missing") + } + if !strings.Contains(err.Error(), "jmx host is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestJMXProviderTestConnectionReturnsErrorWhenPortInvalid(t *testing.T) { + provider := NewJMXProvider() + + err := provider.TestConnection(context.Background(), connection.ConnectionConfig{ + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + JMX: connection.JVMJMXConfig{ + Port: 0, + }, + }, + }) + + if err == nil { + t.Fatal("expected error when jmx port is invalid") + } + if !strings.Contains(err.Error(), "jmx port is invalid") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestHTTPProviderTestConnectionReturnsErrorWhenBaseURLMissing(t *testing.T) { + provider := NewHTTPProvider() + + err := provider.TestConnection(context.Background(), connection.ConnectionConfig{ + Type: "jvm", + JVM: connection.JVMConfig{ + Endpoint: connection.JVMEndpointConfig{ + BaseURL: "", + }, + }, + }) + + if err == nil { + t.Fatal("expected error when endpoint baseURL is missing") + } + if !strings.Contains(err.Error(), "endpoint baseURL is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestHTTPProviderTestConnectionReturnsErrorWhenBaseURLInvalid(t *testing.T) { + provider := NewHTTPProvider() + + err := provider.TestConnection(context.Background(), connection.ConnectionConfig{ + Type: "jvm", + JVM: connection.JVMConfig{ + Endpoint: connection.JVMEndpointConfig{ + BaseURL: "://bad-url", + }, + }, + }) + + if err == nil { + t.Fatal("expected error when endpoint baseURL is invalid") + } + if !strings.Contains(err.Error(), "endpoint baseURL is invalid") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestJMXProviderListResourcesReturnsNotImplementedError(t *testing.T) { + provider := NewJMXProvider() + + _, err := provider.ListResources(context.Background(), connection.ConnectionConfig{}, "") + if err == nil { + t.Fatal("expected not implemented error") + } + if !strings.Contains(strings.ToLower(err.Error()), "does not implement") { + t.Fatalf("unexpected error: %v", err) + } +}