diff --git a/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md b/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md index 6929cec..d578e78 100644 --- a/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md +++ b/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md @@ -26,7 +26,7 @@ - [x] 阶段 2(影响分析):完成 - [x] 阶段 3(方案设计):完成(已形成正式设计文档) - [x] 阶段 4(实施计划):完成(已形成正式实施计划) -- [ ] 阶段 5(实现与自检): +- [ ] 阶段 5(实现与自检):进行中(Task 1 已完成并通过回归) - [ ] 阶段 6(评审与交付): - [ ] 阶段 7(发布与观察): @@ -40,10 +40,11 @@ - 用户明确目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach - 已形成 JVM 缓存可视化编辑正式设计文档 - 已形成 JVM Connector MVP 正式实施计划文档 +- - 已完成 Task 1:JVM 共享契约与配置归一化 - 进行中: - - 等待用户选择执行方式并进入实现 + - Task 2:建立后端 Provider 注册与连接探测 API - 待处理: - - 进入 MVP 分期实施与验证 + - Task 3+:Guard/Audit/App/UI/AI 结构化计划等后续任务 ## 5. 风险与阻塞 - 风险: @@ -71,10 +72,13 @@ - GoNavi 现有 Redis/编辑器/UI 复用能力核查 - JVM Connector 正式设计文档自检 - JVM Connector 实施计划文档自检 + - Task 1:JVM 共享契约与配置归一化 - 结果: - 已确认存在可复用的连接桥接与编辑器基础设施 - 已完成正式设计文档落盘与自检,未发现占位词和明显范围冲突 - 已完成正式实施计划落盘与自检,已补齐共享 DTO、provider factory 和审计落盘等关键实现细节 + - 已完成 JVM 连接共享契约、默认只读/默认 JMX 归一化、前端配置收敛与补测 + - Task 1 已完成规格审查与代码质量审查,结论均通过 - 证据(日志/截图/链接): - `cmd/optional-driver-agent/main.go` - `internal/db/database.go` @@ -83,7 +87,19 @@ - `frontend/src/components/QueryEditor.tsx` - `docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md` - `docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md` + - `internal/connection/types.go` + - `internal/jvm/types.go` + - `internal/jvm/config.go` + - `internal/jvm/config_test.go` + - `frontend/src/types.ts` + - `frontend/src/utils/jvmConnectionConfig.ts` + - `frontend/src/utils/jvmConnectionConfig.test.ts` + - `go test ./internal/jvm -count=1` + - `go test ./...` + - `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts` + - `cd frontend && npm test -- --run` + - `cd frontend && npm run build` ## 8. 下一步 -- 下一步行动:请用户选择实施执行方式;推荐按 task 粒度执行并在每个 task 后做回归和提交 +- 下一步行动:进入 Task 2,建立 JVM Provider 注册、连接测试与能力探测 API,并在完成后生成/校验 Wails 绑定代码 - 负责人:Codex diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 9f575b0..97c61da 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -21,6 +21,72 @@ export interface HTTPTunnelConfig { password?: string; } +export interface JVMJMXConfig { + enabled?: boolean; + host?: string; + port?: number; + username?: string; + password?: string; + domainAllowlist?: string[]; +} + +export interface JVMEndpointConfig { + enabled?: boolean; + baseUrl?: string; + apiKey?: string; + timeoutSeconds?: number; +} + +export interface JVMConfig { + environment?: 'dev' | 'uat' | 'prod'; + readOnly?: boolean; + allowedModes?: Array<'jmx' | 'endpoint' | 'agent'>; + preferredMode?: 'jmx' | 'endpoint' | 'agent'; + jmx?: JVMJMXConfig; + endpoint?: JVMEndpointConfig; +} + +export interface JVMCapability { + mode: 'jmx' | 'endpoint' | 'agent'; + canBrowse: boolean; + canWrite: boolean; + canPreview: boolean; + reason?: string; + displayLabel: string; +} + +export interface JVMResourceSummary { + id: string; + parentId?: string; + kind: string; + name: string; + path: string; + providerMode: 'jmx' | 'endpoint' | 'agent'; + canRead: boolean; + canWrite: boolean; + hasChildren: boolean; + sensitive?: boolean; +} + +export interface JVMValueSnapshot { + resourceId: string; + kind: string; + format: string; + version?: string; + value: any; + metadata?: Record; +} + +export interface JVMChangePreview { + allowed: boolean; + requiresConfirmation?: boolean; + summary: string; + riskLevel: 'low' | 'medium' | 'high'; + blockingReason?: string; + before: JVMValueSnapshot; + after: JVMValueSnapshot; +} + export interface ConnectionConfig { id?: string; type: string; @@ -56,6 +122,7 @@ export interface ConnectionConfig { mongoAuthMechanism?: string; mongoReplicaUser?: string; mongoReplicaPassword?: string; + jvm?: JVMConfig; } export interface MongoMemberInfo { @@ -344,4 +411,3 @@ export interface SecurityUpdateStatus { lastError?: string; } - diff --git a/frontend/src/utils/jvmConnectionConfig.test.ts b/frontend/src/utils/jvmConnectionConfig.test.ts new file mode 100644 index 0000000..be247ca --- /dev/null +++ b/frontend/src/utils/jvmConnectionConfig.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { buildDefaultJVMConnectionValues, buildJVMConnectionConfig } from './jvmConnectionConfig'; + +describe('jvmConnectionConfig', () => { + it('defaults to readonly jmx mode', () => { + const values = buildDefaultJVMConnectionValues(); + expect(values.type).toBe('jvm'); + expect(values.jvmReadOnly).toBe(true); + expect(values.jvmAllowedModes).toEqual(['jmx']); + expect(values.jvmPreferredMode).toBe('jmx'); + }); + + it('builds nested jvm config payload', () => { + const config = buildJVMConnectionConfig({ + name: 'Orders JVM', + type: 'jvm', + host: 'orders.internal', + port: 9010, + jvmReadOnly: true, + jvmAllowedModes: ['jmx', 'endpoint'], + jvmPreferredMode: 'endpoint', + jvmEnvironment: 'prod', + jvmEndpointEnabled: true, + jvmEndpointBaseUrl: 'https://orders.internal/manage/jvm', + jvmEndpointApiKey: 'token-1', + }); + 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', () => { + const config = buildJVMConnectionConfig({ + host: 'cache.internal', + port: 9010, + jvmAllowedModes: [' Endpoint ', 'invalid', 'JMX', 'endpoint'], + jvmPreferredMode: 'AGENT', + }); + + 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', () => { + const config = buildJVMConnectionConfig({ + host: 'orders.internal', + port: 0, + jvmJmxPort: '', + jvmEnvironment: ' PROD ', + jvmReadOnly: false, + 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?.readOnly).toBe(false); + }); +}); diff --git a/frontend/src/utils/jvmConnectionConfig.ts b/frontend/src/utils/jvmConnectionConfig.ts new file mode 100644 index 0000000..7d0f6d3 --- /dev/null +++ b/frontend/src/utils/jvmConnectionConfig.ts @@ -0,0 +1,123 @@ +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; + +type JVMMode = typeof JVM_MODES[number]; +type JVMEnvironment = 'dev' | 'uat' | 'prod'; +type JVMConnectionFormValues = Record; + +const isJVMMode = (value: string): value is JVMMode => JVM_MODES.includes(value as JVMMode); + +const toStringValue = (value: unknown): string => { + if (typeof value === 'string') { + return value.trim(); + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value).trim(); + } + return ''; +}; + +const toInteger = (value: unknown, fallback: number): number => { + if (value === undefined || value === null || value === '') { + return fallback; + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return fallback; + } + const intValue = Math.trunc(parsed); + return intValue > 0 ? intValue : fallback; +}; + +const normalizeModes = (value: unknown): JVMMode[] => { + if (!Array.isArray(value)) { + return ['jmx']; + } + + const result: JVMMode[] = []; + const seen = new Set(); + for (const item of value) { + const mode = toStringValue(item).toLowerCase(); + if (!isJVMMode(mode) || seen.has(mode)) { + continue; + } + seen.add(mode); + result.push(mode); + } + return result.length > 0 ? result : ['jmx']; +}; + +const normalizePreferredMode = (value: unknown, allowedModes: JVMMode[]): JVMMode => { + const preferred = toStringValue(value).toLowerCase(); + if (isJVMMode(preferred) && allowedModes.includes(preferred)) { + return preferred; + } + return allowedModes[0]; +}; + +const normalizeEnvironment = (value: unknown): JVMEnvironment => { + const env = toStringValue(value).toLowerCase(); + if (env === 'uat' || env === 'prod') { + return env; + } + return DEFAULT_ENVIRONMENT; +}; + +const normalizeReadOnly = (value: unknown): boolean => { + if (typeof value === 'boolean') { + return value; + } + return true; +}; + +export const buildDefaultJVMConnectionValues = () => ({ + type: 'jvm', + host: 'localhost', + port: DEFAULT_JMX_PORT, + jvmReadOnly: true, + jvmAllowedModes: ['jmx'], + jvmPreferredMode: 'jmx', + jvmEnvironment: DEFAULT_ENVIRONMENT, + jvmEndpointEnabled: false, + jvmEndpointBaseUrl: '', + jvmEndpointApiKey: '', +}); + +export const buildJVMConnectionConfig = (values: JVMConnectionFormValues): ConnectionConfig => { + const allowedModes = normalizeModes(values.jvmAllowedModes); + const preferredMode = normalizePreferredMode(values.jvmPreferredMode, allowedModes); + const port = toInteger(values.port, DEFAULT_JMX_PORT); + const timeout = toInteger(values.timeout, DEFAULT_TIMEOUT_SECONDS); + + return { + type: 'jvm', + host: toStringValue(values.host), + port, + user: '', + password: '', + timeout, + jvm: { + environment: normalizeEnvironment(values.jvmEnvironment), + readOnly: normalizeReadOnly(values.jvmReadOnly), + allowedModes, + preferredMode, + jmx: { + enabled: allowedModes.includes('jmx'), + host: toStringValue(values.jvmJmxHost) || toStringValue(values.host), + port: toInteger(values.jvmJmxPort, port), + username: toStringValue(values.jvmJmxUsername), + password: toStringValue(values.jvmJmxPassword), + }, + endpoint: { + enabled: values.jvmEndpointEnabled === true, + baseUrl: toStringValue(values.jvmEndpointBaseUrl), + apiKey: toStringValue(values.jvmEndpointApiKey), + timeoutSeconds: toInteger(values.jvmEndpointTimeoutSeconds, timeout), + }, + }, + }; +}; diff --git a/internal/connection/types.go b/internal/connection/types.go index e6e770e..469c091 100644 --- a/internal/connection/types.go +++ b/internal/connection/types.go @@ -26,6 +26,34 @@ type HTTPTunnelConfig struct { Password string `json:"password,omitempty"` } +// JVMJMXConfig 存储 JVM JMX 连接配置。 +type JVMJMXConfig struct { + Enabled bool `json:"enabled,omitempty"` + Host string `json:"host,omitempty"` + Port int `json:"port,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + DomainAllowlist []string `json:"domainAllowlist,omitempty"` +} + +// JVMEndpointConfig 存储 JVM Management Endpoint 连接配置。 +type JVMEndpointConfig struct { + Enabled bool `json:"enabled,omitempty"` + BaseURL string `json:"baseUrl,omitempty"` + APIKey string `json:"apiKey,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"` +} + // ConnectionConfig 存储数据库连接的完整配置,包括 SSH、代理、SSL 等网络层设置。 type ConnectionConfig struct { ID string `json:"id,omitempty"` @@ -62,6 +90,7 @@ type ConnectionConfig struct { MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password + JVM JVMConfig `json:"jvm,omitempty"` // JVM connector config } // ResultSetData 表示一个查询结果集(行 + 列名),用于多结果集场景。 diff --git a/internal/jvm/config.go b/internal/jvm/config.go new file mode 100644 index 0000000..381f690 --- /dev/null +++ b/internal/jvm/config.go @@ -0,0 +1,82 @@ +package jvm + +import ( + "fmt" + "strings" + + "GoNavi-Wails/internal/connection" +) + +const defaultJMXPort = 9010 + +func NormalizeConnectionConfig(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + cfg := raw + if strings.ToLower(strings.TrimSpace(cfg.Type)) != "jvm" { + return connection.ConnectionConfig{}, fmt.Errorf("unexpected connection type: %s", cfg.Type) + } + + cfg.Type = "jvm" + cfg.JVM.Environment = strings.ToLower(strings.TrimSpace(cfg.JVM.Environment)) + if cfg.JVM.ReadOnly == nil { + cfg.JVM.ReadOnly = boolPtr(true) + } + if cfg.JVM.JMX.Port <= 0 { + if cfg.Port > 0 { + cfg.JVM.JMX.Port = cfg.Port + } else { + cfg.JVM.JMX.Port = defaultJMXPort + } + } + + cfg.JVM.AllowedModes = normalizeModes(cfg.JVM.AllowedModes) + + preferredMode := strings.ToLower(strings.TrimSpace(cfg.JVM.PreferredMode)) + if preferredMode == "" || !containsMode(cfg.JVM.AllowedModes, preferredMode) { + cfg.JVM.PreferredMode = cfg.JVM.AllowedModes[0] + } else { + cfg.JVM.PreferredMode = preferredMode + } + + return cfg, nil +} + +func normalizeModes(input []string) []string { + if len(input) == 0 { + return []string{ModeJMX} + } + + result := make([]string, 0, len(input)) + seen := make(map[string]struct{}, len(input)) + for _, item := range input { + mode := strings.ToLower(strings.TrimSpace(item)) + switch mode { + case ModeJMX, ModeEndpoint, ModeAgent: + default: + continue + } + if _, exists := seen[mode]; exists { + continue + } + seen[mode] = struct{}{} + result = append(result, mode) + } + + if len(result) == 0 { + return []string{ModeJMX} + } + return result +} + +func containsMode(items []string, target string) bool { + normalizedTarget := strings.ToLower(strings.TrimSpace(target)) + for _, item := range items { + if strings.ToLower(strings.TrimSpace(item)) == normalizedTarget { + return true + } + } + return false +} + +func boolPtr(value bool) *bool { + return &value +} diff --git a/internal/jvm/config_test.go b/internal/jvm/config_test.go new file mode 100644 index 0000000..a270d02 --- /dev/null +++ b/internal/jvm/config_test.go @@ -0,0 +1,93 @@ +package jvm + +import ( + "testing" + + "GoNavi-Wails/internal/connection" +) + +func TestNormalizeConnectionConfigDefaultsToReadOnlyJMX(t *testing.T) { + raw := connection.ConnectionConfig{ + Type: "jvm", + Host: "orders-prod.internal", + Port: 9010, + } + + got, err := NormalizeConnectionConfig(raw) + if err != nil { + t.Fatalf("NormalizeConnectionConfig returned error: %v", err) + } + if got.JVM.ReadOnly == nil || !*got.JVM.ReadOnly { + t.Fatalf("expected JVM connection to default to readOnly") + } + if got.JVM.PreferredMode != ModeJMX { + t.Fatalf("expected preferred mode %q, got %q", ModeJMX, got.JVM.PreferredMode) + } + if len(got.JVM.AllowedModes) != 1 || got.JVM.AllowedModes[0] != ModeJMX { + t.Fatalf("expected allowed modes [jmx], got %#v", got.JVM.AllowedModes) + } + if got.JVM.JMX.Port != 9010 { + t.Fatalf("expected JMX port to inherit root port 9010, got %d", got.JVM.JMX.Port) + } +} + +func TestNormalizeConnectionConfigFallsBackToFirstAllowedMode(t *testing.T) { + raw := connection.ConnectionConfig{ + Type: "jvm", + Host: "cache-svc.internal", + JVM: connection.JVMConfig{ + AllowedModes: []string{ModeEndpoint, ModeJMX}, + PreferredMode: ModeAgent, + Endpoint: connection.JVMEndpointConfig{ + Enabled: true, + BaseURL: "https://cache-svc.internal/manage/jvm", + }, + }, + } + + got, err := NormalizeConnectionConfig(raw) + if err != nil { + t.Fatalf("NormalizeConnectionConfig returned error: %v", err) + } + if got.JVM.PreferredMode != ModeEndpoint { + t.Fatalf("expected preferred mode %q, got %q", ModeEndpoint, got.JVM.PreferredMode) + } +} + +func TestNormalizeConnectionConfigKeepsExplicitReadOnlyFalse(t *testing.T) { + readOnly := false + raw := connection.ConnectionConfig{ + Type: "jvm", + Port: 9010, + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + }, + } + + got, err := NormalizeConnectionConfig(raw) + if err != nil { + t.Fatalf("NormalizeConnectionConfig returned error: %v", err) + } + if got.JVM.ReadOnly == nil { + t.Fatalf("expected readOnly to remain explicitly configured") + } + if *got.JVM.ReadOnly { + t.Fatalf("expected explicit readOnly=false to be preserved") + } +} + +func TestNormalizeConnectionConfigDefaultsJMXPortTo9010WhenPortsMissing(t *testing.T) { + raw := connection.ConnectionConfig{ + Type: "jvm", + Host: "orders-prod.internal", + Port: 0, + } + + got, err := NormalizeConnectionConfig(raw) + if err != nil { + t.Fatalf("NormalizeConnectionConfig returned error: %v", err) + } + if got.JVM.JMX.Port != 9010 { + t.Fatalf("expected JMX port default 9010, got %d", got.JVM.JMX.Port) + } +} diff --git a/internal/jvm/types.go b/internal/jvm/types.go new file mode 100644 index 0000000..efbcf93 --- /dev/null +++ b/internal/jvm/types.go @@ -0,0 +1,74 @@ +package jvm + +const ( + ModeJMX = "jmx" + ModeEndpoint = "endpoint" + ModeAgent = "agent" + EnvPROD = "prod" +) + +type Capability struct { + Mode string `json:"mode"` + CanBrowse bool `json:"canBrowse"` + CanWrite bool `json:"canWrite"` + CanPreview bool `json:"canPreview"` + Reason string `json:"reason,omitempty"` + DisplayLabel string `json:"displayLabel"` +} + +type ResourceSummary struct { + ID string `json:"id"` + ParentID string `json:"parentId,omitempty"` + Kind string `json:"kind"` + Name string `json:"name"` + Path string `json:"path"` + ProviderMode string `json:"providerMode"` + CanRead bool `json:"canRead"` + CanWrite bool `json:"canWrite"` + HasChildren bool `json:"hasChildren"` + Sensitive bool `json:"sensitive,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"` +} + +type ChangeRequest struct { + ProviderMode string `json:"providerMode"` + ResourceID string `json:"resourceId"` + Action string `json:"action"` + Reason string `json:"reason"` + ExpectedVersion string `json:"expectedVersion,omitempty"` + Payload map[string]any `json:"payload,omitempty"` +} + +type ChangePreview struct { + Allowed bool `json:"allowed"` + RequiresConfirmation bool `json:"requiresConfirmation,omitempty"` + Summary string `json:"summary"` + RiskLevel string `json:"riskLevel"` + BlockingReason string `json:"blockingReason,omitempty"` + Before ValueSnapshot `json:"before"` + After ValueSnapshot `json:"after"` +} + +type ApplyResult struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` + UpdatedValue ValueSnapshot `json:"updatedValue"` +} + +type AuditRecord struct { + Timestamp int64 `json:"timestamp"` + ConnectionID string `json:"connectionId"` + ProviderMode string `json:"providerMode"` + ResourceID string `json:"resourceId"` + Action string `json:"action"` + Reason string `json:"reason"` + Result string `json:"result"` +}