diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 848588e..fa8a8b2 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -26a843d5fd071d0c7e9d8022e98eb4e3 \ No newline at end of file +571d014306268cf67665967059cda912 \ No newline at end of file diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 356fb87..a2e8813 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -134,6 +134,8 @@ export function JVMCancelDiagnosticCommand(arg1:connection.ConnectionConfig,arg2 export function JVMExecuteDiagnosticCommand(arg1:connection.ConnectionConfig,arg2:string,arg3:jvm.DiagnosticCommandRequest):Promise; +export function JVMGetMonitoringHistory(arg1:connection.ConnectionConfig,arg2:string):Promise; + export function JVMGetValue(arg1:connection.ConnectionConfig,arg2:string):Promise; export function JVMListAuditRecords(arg1:string,arg2:number):Promise; @@ -150,6 +152,10 @@ export function JVMProbeDiagnosticCapabilities(arg1:connection.ConnectionConfig) export function JVMStartDiagnosticSession(arg1:connection.ConnectionConfig,arg2:jvm.DiagnosticSessionRequest):Promise; +export function JVMStartMonitoring(arg1:connection.ConnectionConfig):Promise; + +export function JVMStopMonitoring(arg1:connection.ConnectionConfig,arg2:string):Promise; + export function ListSQLDirectory(arg1:string):Promise; export function LogWindowDiagnostic(arg1:string,arg2:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 5eaffa3..d00aad1 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -258,6 +258,10 @@ export function JVMExecuteDiagnosticCommand(arg1, arg2, arg3) { return window['go']['app']['App']['JVMExecuteDiagnosticCommand'](arg1, arg2, arg3); } +export function JVMGetMonitoringHistory(arg1, arg2) { + return window['go']['app']['App']['JVMGetMonitoringHistory'](arg1, arg2); +} + export function JVMGetValue(arg1, arg2) { return window['go']['app']['App']['JVMGetValue'](arg1, arg2); } @@ -290,6 +294,14 @@ export function JVMStartDiagnosticSession(arg1, arg2) { return window['go']['app']['App']['JVMStartDiagnosticSession'](arg1, arg2); } +export function JVMStartMonitoring(arg1) { + return window['go']['app']['App']['JVMStartMonitoring'](arg1); +} + +export function JVMStopMonitoring(arg1, arg2) { + return window['go']['app']['App']['JVMStopMonitoring'](arg1, arg2); +} + export function ListSQLDirectory(arg1) { return window['go']['app']['App']['ListSQLDirectory'](arg1); } diff --git a/internal/app/methods_jvm_monitoring.go b/internal/app/methods_jvm_monitoring.go new file mode 100644 index 0000000..8c668e1 --- /dev/null +++ b/internal/app/methods_jvm_monitoring.go @@ -0,0 +1,77 @@ +package app + +import ( + "context" + "fmt" + "strings" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/jvm" +) + +type jvmMonitoringService interface { + Start(ctx context.Context, cfg connection.ConnectionConfig, requestedMode string) (jvm.MonitoringSessionSnapshot, error) + GetHistory(connectionID string, providerMode string) (jvm.MonitoringSessionSnapshot, error) + Stop(connectionID string, providerMode string) error +} + +var currentJVMMonitoringManager jvmMonitoringService = jvm.NewMonitoringManager() + +func (a *App) JVMStartMonitoring(cfg connection.ConnectionConfig) connection.QueryResult { + snapshot, err := currentJVMMonitoringManager.Start(a.ctx, cfg, "") + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Data: snapshot} +} + +func (a *App) JVMGetMonitoringHistory(cfg connection.ConnectionConfig, providerMode string) connection.QueryResult { + connectionID, resolvedMode, err := resolveJVMMonitoringLookup(cfg, providerMode) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + snapshot, err := currentJVMMonitoringManager.GetHistory(connectionID, resolvedMode) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Data: snapshot} +} + +func (a *App) JVMStopMonitoring(cfg connection.ConnectionConfig, providerMode string) connection.QueryResult { + connectionID, resolvedMode, err := resolveJVMMonitoringLookup(cfg, providerMode) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + if err := currentJVMMonitoringManager.Stop(connectionID, resolvedMode); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Data: map[string]any{ + "connectionId": connectionID, + "providerMode": resolvedMode, + "status": "stopped", + }} +} + +func resolveJVMMonitoringLookup(cfg connection.ConnectionConfig, requestedMode string) (string, string, error) { + normalized, resolvedMode, err := jvm.ResolveProviderMode(cfg, requestedMode) + if err != nil { + return "", "", err + } + return resolveJVMMonitoringConnectionID(normalized), resolvedMode, nil +} + +func resolveJVMMonitoringConnectionID(cfg connection.ConnectionConfig) string { + if trimmed := strings.TrimSpace(cfg.ID); trimmed != "" { + return trimmed + } + host := strings.TrimSpace(cfg.Host) + if host == "" { + host = "unknown" + } + if cfg.Port > 0 { + return fmt.Sprintf("%s:%d", host, cfg.Port) + } + return host +} diff --git a/internal/app/methods_jvm_monitoring_test.go b/internal/app/methods_jvm_monitoring_test.go new file mode 100644 index 0000000..acb8b92 --- /dev/null +++ b/internal/app/methods_jvm_monitoring_test.go @@ -0,0 +1,147 @@ +package app + +import ( + "context" + "errors" + "testing" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/jvm" +) + +type fakeJVMMonitoringManager struct { + startSnapshot jvm.MonitoringSessionSnapshot + startErr error + historySnapshot jvm.MonitoringSessionSnapshot + historyErr error + stopErr error + startCfg connection.ConnectionConfig + startMode string + historyConnection string + historyMode string + stopConnection string + stopMode string +} + +func (f *fakeJVMMonitoringManager) Start(_ context.Context, cfg connection.ConnectionConfig, mode string) (jvm.MonitoringSessionSnapshot, error) { + f.startCfg = cfg + f.startMode = mode + return f.startSnapshot, f.startErr +} + +func (f *fakeJVMMonitoringManager) GetHistory(connectionID string, providerMode string) (jvm.MonitoringSessionSnapshot, error) { + f.historyConnection = connectionID + f.historyMode = providerMode + return f.historySnapshot, f.historyErr +} + +func (f *fakeJVMMonitoringManager) Stop(connectionID string, providerMode string) error { + f.stopConnection = connectionID + f.stopMode = providerMode + return f.stopErr +} + +func swapJVMMonitoringManager(manager jvmMonitoringService) func() { + prev := currentJVMMonitoringManager + currentJVMMonitoringManager = manager + return func() { currentJVMMonitoringManager = prev } +} + +func TestJVMStartMonitoringReturnsManagerSnapshot(t *testing.T) { + app := NewAppWithSecretStore(nil) + manager := &fakeJVMMonitoringManager{ + startSnapshot: jvm.MonitoringSessionSnapshot{ + ConnectionID: "conn-monitor", + ProviderMode: jvm.ModeEndpoint, + Running: true, + Points: []jvm.JVMMonitoringPoint{ + {Timestamp: 1713945600000, ThreadCount: 21}, + }, + }, + } + restore := swapJVMMonitoringManager(manager) + defer restore() + + res := app.JVMStartMonitoring(connection.ConnectionConfig{ + ID: "conn-monitor", + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: jvm.ModeEndpoint, + AllowedModes: []string{jvm.ModeEndpoint}, + }, + }) + + if !res.Success { + t.Fatalf("expected success, got %+v", res) + } + snapshot, ok := res.Data.(jvm.MonitoringSessionSnapshot) + if !ok { + t.Fatalf("expected monitoring snapshot, got %#v", res.Data) + } + if !snapshot.Running || len(snapshot.Points) != 1 { + t.Fatalf("unexpected snapshot: %#v", snapshot) + } + if manager.startCfg.ID != "conn-monitor" { + t.Fatalf("expected manager to receive config ID, got %#v", manager.startCfg) + } +} + +func TestJVMGetMonitoringHistoryResolvesPreferredMode(t *testing.T) { + app := NewAppWithSecretStore(nil) + manager := &fakeJVMMonitoringManager{ + historySnapshot: jvm.MonitoringSessionSnapshot{ + ConnectionID: "conn-history", + ProviderMode: jvm.ModeJMX, + Running: true, + }, + } + restore := swapJVMMonitoringManager(manager) + defer restore() + + res := app.JVMGetMonitoringHistory(connection.ConnectionConfig{ + ID: "conn-history", + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: jvm.ModeJMX, + AllowedModes: []string{jvm.ModeJMX}, + }, + }, "") + + if !res.Success { + t.Fatalf("expected success, got %+v", res) + } + if manager.historyConnection != "conn-history" || manager.historyMode != jvm.ModeJMX { + t.Fatalf("unexpected manager history args: connection=%q mode=%q", manager.historyConnection, manager.historyMode) + } +} + +func TestJVMStopMonitoringReturnsManagerError(t *testing.T) { + app := NewAppWithSecretStore(nil) + manager := &fakeJVMMonitoringManager{ + stopErr: errors.New("session not found"), + } + restore := swapJVMMonitoringManager(manager) + defer restore() + + res := app.JVMStopMonitoring(connection.ConnectionConfig{ + ID: "conn-stop", + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: jvm.ModeAgent, + AllowedModes: []string{jvm.ModeAgent}, + }, + }, "") + + if res.Success { + t.Fatalf("expected failure, got %+v", res) + } + if res.Message != "session not found" { + t.Fatalf("expected message %q, got %#v", "session not found", res) + } + if manager.stopConnection != "conn-stop" || manager.stopMode != jvm.ModeAgent { + t.Fatalf("unexpected manager stop args: connection=%q mode=%q", manager.stopConnection, manager.stopMode) + } +} diff --git a/internal/jvm/agent_provider.go b/internal/jvm/agent_provider.go index e1f66f8..c21ccd2 100644 --- a/internal/jvm/agent_provider.go +++ b/internal/jvm/agent_provider.go @@ -91,6 +91,20 @@ func (p *AgentProvider) GetValue(ctx context.Context, cfg connection.ConnectionC return snapshot, nil } +func (p *AgentProvider) GetMonitoringSnapshot(ctx context.Context, cfg connection.ConnectionConfig, previous *JVMMonitoringPoint) (JVMMonitoringSnapshot, error) { + runtime, err := newAgentRuntime(cfg) + if err != nil { + return JVMMonitoringSnapshot{}, err + } + + var snapshot JVMMonitoringSnapshot + if err := runtime.doJSON(ctx, http.MethodGet, "get monitoring snapshot", "metrics", nil, nil, &snapshot); err != nil { + return JVMMonitoringSnapshot{}, err + } + finalizeMonitoringSnapshot(&snapshot, previous) + return snapshot, nil +} + func (p *AgentProvider) PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) { runtime, err := newAgentRuntime(cfg) if err != nil { diff --git a/internal/jvm/agent_provider_test.go b/internal/jvm/agent_provider_test.go index e6db9e2..efba2d5 100644 --- a/internal/jvm/agent_provider_test.go +++ b/internal/jvm/agent_provider_test.go @@ -59,6 +59,51 @@ func TestAgentProviderListResourcesBuildsRequestAndDecodesResponse(t *testing.T) } } +func TestAgentProviderGetMonitoringSnapshotDecodesResponse(t *testing.T) { + provider := &AgentProvider{} + 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/metrics" { + t.Fatalf("expected path /gonavi/agent/jvm/metrics, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(JVMMonitoringSnapshot{ + Point: JVMMonitoringPoint{ + Timestamp: 1713945600000, + ThreadCount: 27, + HeapUsedBytes: 402653184, + GCCollectionCount: 128, + GCDeltaCount: 2, + ProcessCpuLoad: 0.29, + CommittedVirtualMemoryBytes: 2147483648, + }, + RecentGCEvents: []RecentGCEvent{{ + Timestamp: 1713945600000, + Name: "ConcurrentMarkSweep", + DurationMs: 12, + }}, + AvailableMetrics: []string{"thread.count", "heap.used", "gc.count", "cpu.process", "memory.virtual"}, + }) + })) + defer server.Close() + + snapshot, err := provider.GetMonitoringSnapshot(context.Background(), newAgentProviderTestConfig(server.URL+"/gonavi/agent/jvm", 3), nil) + if err != nil { + t.Fatalf("GetMonitoringSnapshot returned error: %v", err) + } + if snapshot.Point.ThreadCount != 27 || snapshot.Point.GCDeltaCount != 2 || snapshot.Point.ProcessCpuLoad != 0.29 { + t.Fatalf("unexpected monitoring snapshot: %#v", snapshot) + } + if len(snapshot.RecentGCEvents) != 1 || snapshot.RecentGCEvents[0].Name != "ConcurrentMarkSweep" { + t.Fatalf("unexpected recent gc events: %#v", snapshot.RecentGCEvents) + } + if len(snapshot.AvailableMetrics) != 5 { + t.Fatalf("unexpected available metrics: %#v", snapshot) + } +} + func TestAgentProviderRealAgentRoundTrip(t *testing.T) { if _, err := exec.LookPath("java"); err != nil { t.Skipf("java 不可用,跳过真实 Agent 集成测试: %v", err) @@ -214,7 +259,18 @@ func startAgentFixture(t *testing.T) agentFixtureProcess { t.Fatalf("write agent manifest failed: %v", err) } - agentJar := filepath.Join(t.TempDir(), "gonavi-test-agent.jar") + agentJarFile, err := os.CreateTemp("", "gonavi-test-agent-*.jar") + if err != nil { + t.Fatalf("create agent jar temp file failed: %v", err) + } + agentJar := agentJarFile.Name() + if closeErr := agentJarFile.Close(); closeErr != nil { + t.Fatalf("close agent jar temp file failed: %v", closeErr) + } + _ = os.Remove(agentJar) + t.Cleanup(func() { + _ = os.Remove(agentJar) + }) jarCmd := exec.Command(jarBin, "cmf", manifestPath, agentJar, "-C", classesDir, "com") output, err = jarCmd.CombinedOutput() if err != nil { diff --git a/internal/jvm/http_provider.go b/internal/jvm/http_provider.go index b347ce5..ce71d9e 100644 --- a/internal/jvm/http_provider.go +++ b/internal/jvm/http_provider.go @@ -92,6 +92,20 @@ func (p *HTTPProvider) GetValue(ctx context.Context, cfg connection.ConnectionCo return snapshot, nil } +func (p *HTTPProvider) GetMonitoringSnapshot(ctx context.Context, cfg connection.ConnectionConfig, previous *JVMMonitoringPoint) (JVMMonitoringSnapshot, error) { + runtime, err := newEndpointRuntime(cfg) + if err != nil { + return JVMMonitoringSnapshot{}, err + } + + var snapshot JVMMonitoringSnapshot + if err := runtime.doJSON(ctx, http.MethodGet, "get monitoring snapshot", "metrics", nil, nil, &snapshot); err != nil { + return JVMMonitoringSnapshot{}, err + } + finalizeMonitoringSnapshot(&snapshot, previous) + return snapshot, nil +} + func (p *HTTPProvider) PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) { runtime, err := newEndpointRuntime(cfg) if err != nil { diff --git a/internal/jvm/http_provider_test.go b/internal/jvm/http_provider_test.go index 2486325..d5acc6c 100644 --- a/internal/jvm/http_provider_test.go +++ b/internal/jvm/http_provider_test.go @@ -93,6 +93,52 @@ func TestHTTPProviderGetValueDecodesResponse(t *testing.T) { } } +func TestHTTPProviderGetMonitoringSnapshotDecodesResponse(t *testing.T) { + provider := &HTTPProvider{} + 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/metrics" { + t.Fatalf("expected path /manage/jvm/metrics, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(JVMMonitoringSnapshot{ + Point: JVMMonitoringPoint{ + Timestamp: 1713945600000, + ThreadCount: 18, + HeapUsedBytes: 805306368, + ProcessCpuLoad: 0.48, + ProcessRssBytes: 1879048192, + LoadedClassCount: 4096, + }, + RecentGCEvents: []RecentGCEvent{{ + Timestamp: 1713945600000, + Name: "G1 Old Generation", + DurationMs: 41, + }}, + AvailableMetrics: []string{"thread.count", "heap.used", "cpu.process", "memory.rss", "class.loading"}, + MissingMetrics: []string{"cpu.system"}, + ProviderWarnings: []string{"endpoint cpu metric unavailable"}, + }) + })) + defer server.Close() + + snapshot, err := provider.GetMonitoringSnapshot(context.Background(), newHTTPProviderTestConfig(server.URL+"/manage/jvm", 3), nil) + if err != nil { + t.Fatalf("GetMonitoringSnapshot returned error: %v", err) + } + if snapshot.Point.ThreadCount != 18 || snapshot.Point.HeapUsedBytes != 805306368 || snapshot.Point.ProcessRssBytes != 1879048192 { + t.Fatalf("unexpected monitoring snapshot: %#v", snapshot) + } + if len(snapshot.RecentGCEvents) != 1 || snapshot.RecentGCEvents[0].Name != "G1 Old Generation" { + t.Fatalf("unexpected recent gc events: %#v", snapshot.RecentGCEvents) + } + if len(snapshot.MissingMetrics) != 1 || snapshot.MissingMetrics[0] != "cpu.system" { + t.Fatalf("unexpected missing metrics: %#v", snapshot) + } +} + func TestHTTPProviderPreviewChangeAndApplySendJSONBody(t *testing.T) { provider := NewHTTPProvider() request := ChangeRequest{ diff --git a/internal/jvm/jmx_helper.go b/internal/jvm/jmx_helper.go index 40492d6..c4cca2b 100644 --- a/internal/jvm/jmx_helper.go +++ b/internal/jvm/jmx_helper.go @@ -12,6 +12,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "sort" "strconv" "strings" @@ -32,6 +33,7 @@ const ( jmxHelperCommandPing = "ping" jmxHelperCommandList = "list" jmxHelperCommandGet = "get" + jmxHelperCommandMonitor = "monitor" jmxHelperCommandPreview = "preview" jmxHelperCommandApply = "apply" @@ -44,6 +46,11 @@ var ( jmxHelperLookPath = exec.LookPath ) +var ( + jmxHelperSensitiveJSONFieldPattern = regexp.MustCompile(`(?i)("(?:password|apiKey|token|secret)"\s*:\s*")([^"]*)(")`) + jmxHelperSensitivePairPattern = regexp.MustCompile(`(?i)\b(password|api[_-]?key|token|secret)(\s*[:=]\s*)([^&\s;,"'}]+)`) +) + //go:embed jmxhelper_assets/jmx-helper-runtime.jar var embeddedJMXHelperJar []byte @@ -94,13 +101,14 @@ type jmxHelperChangePlan struct { } 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"` + 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"` + MonitoringSnapshot *jmxHelperMonitoringSnapshot `json:"monitoringSnapshot,omitempty"` + Preview *jmxHelperPreview `json:"preview,omitempty"` + ApplyResult *jmxHelperApplyResponse `json:"applyResult,omitempty"` } type jmxHelperResource struct { @@ -127,6 +135,38 @@ type jmxHelperSnapshot struct { Metadata map[string]any `json:"metadata,omitempty"` } +type jmxHelperMonitoringPoint struct { + Timestamp int64 `json:"timestamp"` + HeapUsedBytes int64 `json:"heapUsedBytes,omitempty"` + HeapCommittedBytes int64 `json:"heapCommittedBytes,omitempty"` + HeapMaxBytes int64 `json:"heapMaxBytes,omitempty"` + NonHeapUsedBytes int64 `json:"nonHeapUsedBytes,omitempty"` + NonHeapCommittedBytes int64 `json:"nonHeapCommittedBytes,omitempty"` + GCCollectionCount int64 `json:"gcCollectionCount,omitempty"` + GCCollectionTimeMs int64 `json:"gcCollectionTimeMs,omitempty"` + GCDeltaCount int64 `json:"gcDeltaCount,omitempty"` + GCDeltaTimeMs int64 `json:"gcDeltaTimeMs,omitempty"` + ThreadCount int `json:"threadCount,omitempty"` + DaemonThreadCount int `json:"daemonThreadCount,omitempty"` + PeakThreadCount int `json:"peakThreadCount,omitempty"` + ThreadStateCounts map[string]int `json:"threadStateCounts,omitempty"` + LoadedClassCount int `json:"loadedClassCount,omitempty"` + UnloadedClassCount int64 `json:"unloadedClassCount,omitempty"` + ClassLoadDelta int64 `json:"classLoadDelta,omitempty"` + ProcessCpuLoad float64 `json:"processCpuLoad,omitempty"` + SystemCpuLoad float64 `json:"systemCpuLoad,omitempty"` + ProcessRssBytes int64 `json:"processRssBytes,omitempty"` + CommittedVirtualMemoryBytes int64 `json:"committedVirtualMemoryBytes,omitempty"` +} + +type jmxHelperMonitoringSnapshot struct { + Point jmxHelperMonitoringPoint `json:"point"` + RecentGCEvents []RecentGCEvent `json:"recentGcEvents,omitempty"` + AvailableMetrics []string `json:"availableMetrics,omitempty"` + MissingMetrics []string `json:"missingMetrics,omitempty"` + ProviderWarnings []string `json:"providerWarnings,omitempty"` +} + type jmxHelperPreview struct { Allowed bool `json:"allowed"` RequiresConfirmation bool `json:"requiresConfirmation,omitempty"` @@ -366,6 +406,11 @@ func helperContextSummary(cfg connection.ConnectionConfig, target *jmxResourceTa } } +func redactJMXHelperOutput(text string) string { + redacted := jmxHelperSensitiveJSONFieldPattern.ReplaceAllString(text, `${1}${3}`) + return jmxHelperSensitivePairPattern.ReplaceAllString(redacted, `${1}${2}`) +} + func runJMXHelper( ctx context.Context, cfg connection.ConnectionConfig, @@ -414,6 +459,7 @@ func runJMXHelper( defer cancel() cmd := jmxHelperCommandContext(execCtx, runtimeInfo.javaBinary, "-cp", runtimeInfo.classpath, jmxHelperMainClass) + configureJMXHelperCommand(cmd) cmd.Stdin = bytes.NewReader(input) var stdout bytes.Buffer var stderr bytes.Buffer @@ -421,7 +467,7 @@ func runJMXHelper( cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - stderrText := strings.TrimSpace(stderr.String()) + stderrText := strings.TrimSpace(redactJMXHelperOutput(stderr.String())) if stderrText == "" { stderrText = "" } @@ -436,23 +482,24 @@ func runJMXHelper( var response jmxHelperResponse if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { + stdoutText := strings.TrimSpace(redactJMXHelperOutput(stdout.String())) return jmxHelperResponse{}, fmt.Errorf( "decode JMX helper %s response failed for %s: %w; stdout: %s", command, helperContextSummary(cfg, target), err, - strings.TrimSpace(stdout.String()), + stdoutText, ) } if !response.OK { - errText := strings.TrimSpace(response.Error) + errText := strings.TrimSpace(redactJMXHelperOutput(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) + errText += "; details=" + redactJMXHelperOutput(string(detailsJSON)) } } return jmxHelperResponse{}, fmt.Errorf("jmx helper %s failed for %s: %s", command, helperContextSummary(cfg, target), errText) @@ -649,6 +696,42 @@ func previewFromHelper(target jmxResourceTarget, preview *jmxHelperPreview) (Cha return result, nil } +func monitoringSnapshotFromHelper(snapshot *jmxHelperMonitoringSnapshot) (JVMMonitoringSnapshot, error) { + if snapshot == nil { + return JVMMonitoringSnapshot{}, fmt.Errorf("helper did not return monitoring snapshot") + } + + return JVMMonitoringSnapshot{ + Point: JVMMonitoringPoint{ + Timestamp: snapshot.Point.Timestamp, + HeapUsedBytes: snapshot.Point.HeapUsedBytes, + HeapCommittedBytes: snapshot.Point.HeapCommittedBytes, + HeapMaxBytes: snapshot.Point.HeapMaxBytes, + NonHeapUsedBytes: snapshot.Point.NonHeapUsedBytes, + NonHeapCommittedBytes: snapshot.Point.NonHeapCommittedBytes, + GCCollectionCount: snapshot.Point.GCCollectionCount, + GCCollectionTimeMs: snapshot.Point.GCCollectionTimeMs, + GCDeltaCount: snapshot.Point.GCDeltaCount, + GCDeltaTimeMs: snapshot.Point.GCDeltaTimeMs, + ThreadCount: snapshot.Point.ThreadCount, + DaemonThreadCount: snapshot.Point.DaemonThreadCount, + PeakThreadCount: snapshot.Point.PeakThreadCount, + ThreadStateCounts: cloneStringIntMap(snapshot.Point.ThreadStateCounts), + LoadedClassCount: snapshot.Point.LoadedClassCount, + UnloadedClassCount: snapshot.Point.UnloadedClassCount, + ClassLoadDelta: snapshot.Point.ClassLoadDelta, + ProcessCpuLoad: snapshot.Point.ProcessCpuLoad, + SystemCpuLoad: snapshot.Point.SystemCpuLoad, + ProcessRssBytes: snapshot.Point.ProcessRssBytes, + CommittedVirtualMemoryBytes: snapshot.Point.CommittedVirtualMemoryBytes, + }, + RecentGCEvents: append([]RecentGCEvent(nil), snapshot.RecentGCEvents...), + AvailableMetrics: append([]string(nil), snapshot.AvailableMetrics...), + MissingMetrics: append([]string(nil), snapshot.MissingMetrics...), + ProviderWarnings: append([]string(nil), snapshot.ProviderWarnings...), + }, 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)) diff --git a/internal/jvm/jmx_helper_process_other.go b/internal/jvm/jmx_helper_process_other.go new file mode 100644 index 0000000..a7fded6 --- /dev/null +++ b/internal/jvm/jmx_helper_process_other.go @@ -0,0 +1,8 @@ +//go:build !windows + +package jvm + +import "os/exec" + +func configureJMXHelperCommand(_ *exec.Cmd) { +} diff --git a/internal/jvm/jmx_helper_process_windows.go b/internal/jvm/jmx_helper_process_windows.go new file mode 100644 index 0000000..96e72fc --- /dev/null +++ b/internal/jvm/jmx_helper_process_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package jvm + +import ( + "os/exec" + "syscall" +) + +func configureJMXHelperCommand(cmd *exec.Cmd) { + if cmd == nil { + return + } + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} +} diff --git a/internal/jvm/jmx_helper_test.go b/internal/jvm/jmx_helper_test.go index c899794..79bf637 100644 --- a/internal/jvm/jmx_helper_test.go +++ b/internal/jvm/jmx_helper_test.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" ) @@ -82,3 +83,18 @@ func TestEnsureJMXHelperRuntimeUsesOverrideClasspath(t *testing.T) { t.Fatalf("expected override mode to skip cache writes, stat err=%v", err) } } + +func TestRedactJMXHelperOutputMasksSensitiveFields(t *testing.T) { + output := `{"password":"secret-pass","apiKey":"agent-token","details":"token=abc123 password: raw-secret"}` + + redacted := redactJMXHelperOutput(output) + + for _, secret := range []string{"secret-pass", "agent-token", "abc123", "raw-secret"} { + if strings.Contains(redacted, secret) { + t.Fatalf("expected %q to be redacted from %q", secret, redacted) + } + } + if !strings.Contains(redacted, "") { + t.Fatalf("expected redaction marker, got %q", redacted) + } +} diff --git a/internal/jvm/jmx_helper_windows_test.go b/internal/jvm/jmx_helper_windows_test.go new file mode 100644 index 0000000..d318588 --- /dev/null +++ b/internal/jvm/jmx_helper_windows_test.go @@ -0,0 +1,18 @@ +//go:build windows + +package jvm + +import ( + "os/exec" + "testing" +) + +func TestConfigureJMXHelperCommandHidesWindowOnWindows(t *testing.T) { + cmd := exec.Command("java") + + configureJMXHelperCommand(cmd) + + if cmd.SysProcAttr == nil || !cmd.SysProcAttr.HideWindow { + t.Fatalf("expected JMX helper command to hide Windows console window, got %#v", cmd.SysProcAttr) + } +} diff --git a/internal/jvm/jmx_provider.go b/internal/jvm/jmx_provider.go index b459b59..6f39bb5 100644 --- a/internal/jvm/jmx_provider.go +++ b/internal/jvm/jmx_provider.go @@ -74,6 +74,19 @@ func (p *JMXProvider) GetValue(ctx context.Context, cfg connection.ConnectionCon return valueSnapshotFromHelper(target, resp.Snapshot) } +func (p *JMXProvider) GetMonitoringSnapshot(ctx context.Context, cfg connection.ConnectionConfig, previous *JVMMonitoringPoint) (JVMMonitoringSnapshot, error) { + resp, err := jmxHelperRunner(ctx, cfg, jmxHelperCommandMonitor, nil, nil) + if err != nil { + return JVMMonitoringSnapshot{}, fmt.Errorf("jmx get monitoring snapshot failed: %w", err) + } + snapshot, err := monitoringSnapshotFromHelper(resp.MonitoringSnapshot) + if err != nil { + return JVMMonitoringSnapshot{}, err + } + finalizeMonitoringSnapshot(&snapshot, previous) + return snapshot, nil +} + func (p *JMXProvider) PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) { target, err := parseRequiredResourcePath(req.ResourceID) if err != nil { diff --git a/internal/jvm/jmx_provider_test.go b/internal/jvm/jmx_provider_test.go index de67b6a..45ca682 100644 --- a/internal/jvm/jmx_provider_test.go +++ b/internal/jvm/jmx_provider_test.go @@ -135,6 +135,51 @@ func TestJMXProviderGetValueUsesHelperSnapshot(t *testing.T) { } } +func TestJMXProviderGetMonitoringSnapshotUsesHelperMonitorCommand(t *testing.T) { + helper := &stubJMXHelper{ + response: jmxHelperResponse{ + MonitoringSnapshot: &jmxHelperMonitoringSnapshot{ + Point: jmxHelperMonitoringPoint{ + Timestamp: 1713945600000, + ThreadCount: 33, + HeapUsedBytes: 536870912, + ProcessCpuLoad: 0.37, + LoadedClassCount: 2048, + ProcessRssBytes: 1610612736, + CommittedVirtualMemoryBytes: 2147483648, + }, + RecentGCEvents: []RecentGCEvent{{ + Timestamp: 1713945600000, + Name: "G1 Young Generation", + Cause: "G1 Evacuation Pause", + DurationMs: 18, + }}, + AvailableMetrics: []string{"thread.count", "heap.used", "class.loading", "memory.rss"}, + MissingMetrics: []string{"cpu.system"}, + }, + }, + } + withStubJMXHelper(t, helper.run) + provider := &JMXProvider{} + + snapshot, err := provider.GetMonitoringSnapshot(context.Background(), newJMXProviderTestConfig(), nil) + if err != nil { + t.Fatalf("GetMonitoringSnapshot returned error: %v", err) + } + if helper.lastRequest.Command != jmxHelperCommandMonitor { + t.Fatalf("expected helper command %q, got %#v", jmxHelperCommandMonitor, helper.lastRequest) + } + if snapshot.Point.ThreadCount != 33 || snapshot.Point.HeapUsedBytes != 536870912 || snapshot.Point.LoadedClassCount != 2048 { + t.Fatalf("unexpected monitoring snapshot: %#v", snapshot) + } + if len(snapshot.RecentGCEvents) != 1 || snapshot.RecentGCEvents[0].DurationMs != 18 { + t.Fatalf("unexpected recent gc events: %#v", snapshot.RecentGCEvents) + } + if len(snapshot.MissingMetrics) != 1 || snapshot.MissingMetrics[0] != "cpu.system" { + t.Fatalf("unexpected missing metrics: %#v", snapshot) + } +} + func TestJMXProviderPreviewAndApplyUseHelperPayload(t *testing.T) { request := ChangeRequest{ ProviderMode: ModeJMX, diff --git a/internal/jvm/jmxhelper_assets/jmx-helper-runtime.jar b/internal/jvm/jmxhelper_assets/jmx-helper-runtime.jar index b5aa8d5..396bbc6 100644 Binary files a/internal/jvm/jmxhelper_assets/jmx-helper-runtime.jar and b/internal/jvm/jmxhelper_assets/jmx-helper-runtime.jar differ diff --git a/internal/jvm/monitoring_manager.go b/internal/jvm/monitoring_manager.go new file mode 100644 index 0000000..de4621c --- /dev/null +++ b/internal/jvm/monitoring_manager.go @@ -0,0 +1,351 @@ +package jvm + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "GoNavi-Wails/internal/connection" +) + +const ( + defaultMonitoringPointLimit = 180 + defaultMonitoringEventLimit = 20 + defaultMonitoringInterval = 2 * time.Second + maxMonitoringSampleFailures = 3 +) + +var monitoringProviderFactory = NewProvider + +type monitoringManager struct { + mu sync.Mutex + limit int + interval time.Duration + sessions map[string]*monitoringSession +} + +type monitoringSession struct { + mu sync.Mutex + connectionID string + providerMode string + limit int + running bool + points []JVMMonitoringPoint + recentGCEvents []RecentGCEvent + availableMetrics []string + missingMetrics []string + providerWarnings []string + cancel context.CancelFunc + generation int64 +} + +func newMonitoringManagerForTest(limit int) *monitoringManager { + return newMonitoringManager(limit, 0) +} + +func NewMonitoringManager() *monitoringManager { + return newMonitoringManager(defaultMonitoringPointLimit, defaultMonitoringInterval) +} + +func newMonitoringManager(limit int, interval time.Duration) *monitoringManager { + if limit <= 0 { + limit = defaultMonitoringPointLimit + } + return &monitoringManager{ + limit: limit, + interval: interval, + sessions: make(map[string]*monitoringSession), + } +} + +func (m *monitoringManager) ensureSession(connectionID string, providerMode string) *monitoringSession { + m.mu.Lock() + defer m.mu.Unlock() + + key := connectionID + ":" + providerMode + if session, ok := m.sessions[key]; ok { + return session + } + + session := &monitoringSession{ + connectionID: connectionID, + providerMode: providerMode, + limit: m.limit, + } + m.sessions[key] = session + return session +} + +func (m *monitoringManager) Start(ctx context.Context, raw connection.ConnectionConfig, requestedMode string) (MonitoringSessionSnapshot, error) { + cfg, providerMode, err := ResolveProviderMode(raw, requestedMode) + if err != nil { + return MonitoringSessionSnapshot{}, err + } + + connectionID := resolveMonitoringConnectionID(cfg) + session := m.ensureSession(connectionID, providerMode) + + provider, err := monitoringProviderFactory(providerMode) + if err != nil { + return MonitoringSessionSnapshot{}, err + } + + monitoringProvider, ok := provider.(MonitoringCapableProvider) + if !ok { + return MonitoringSessionSnapshot{}, fmt.Errorf("%s provider does not implement monitoring snapshot yet", ModeDisplayLabel(providerMode)) + } + + generation := session.reset(connectionID, providerMode) + if err := m.sampleOnce(ctx, monitoringProvider, cfg, session, generation); err != nil { + session.markStopped(generation) + return MonitoringSessionSnapshot{}, err + } + + session.markRunning(generation) + if m.interval > 0 { + loopCtx, cancel := context.WithCancel(context.Background()) + session.setCancel(cancel) + go m.runSampler(loopCtx, monitoringProvider, cfg, session, generation) + } + + return session.snapshot(), nil +} + +func (m *monitoringManager) Stop(connectionID string, providerMode string) error { + m.mu.Lock() + session, ok := m.sessions[m.sessionKey(connectionID, providerMode)] + m.mu.Unlock() + if !ok { + return fmt.Errorf("monitoring session not found for %s %s", connectionID, providerMode) + } + + session.stop() + return nil +} + +func (m *monitoringManager) GetHistory(connectionID string, providerMode string) (MonitoringSessionSnapshot, error) { + m.mu.Lock() + session, ok := m.sessions[m.sessionKey(connectionID, providerMode)] + m.mu.Unlock() + if !ok { + return MonitoringSessionSnapshot{}, fmt.Errorf("monitoring session not found for %s %s", connectionID, providerMode) + } + return session.snapshot(), nil +} + +func (s *monitoringSession) appendPoint(point JVMMonitoringPoint) { + s.mu.Lock() + defer s.mu.Unlock() + + s.points = append(s.points, point) + if len(s.points) > s.limit { + s.points = append([]JVMMonitoringPoint(nil), s.points[len(s.points)-s.limit:]...) + } +} + +func (m *monitoringManager) sessionKey(connectionID string, providerMode string) string { + return strings.TrimSpace(connectionID) + ":" + strings.TrimSpace(providerMode) +} + +func (m *monitoringManager) runSampler(ctx context.Context, provider MonitoringCapableProvider, cfg connection.ConnectionConfig, session *monitoringSession, generation int64) { + ticker := time.NewTicker(m.interval) + defer ticker.Stop() + consecutiveFailures := 0 + + for { + select { + case <-ctx.Done(): + session.markStopped(generation) + return + case <-ticker.C: + if err := m.sampleOnce(ctx, provider, cfg, session, generation); err != nil { + consecutiveFailures++ + session.appendWarning(err.Error()) + if consecutiveFailures >= maxMonitoringSampleFailures { + session.appendWarning(fmt.Sprintf("监控采样连续失败 %d 次,已自动停止本次监控会话", consecutiveFailures)) + session.markStopped(generation) + return + } + continue + } + consecutiveFailures = 0 + } + } +} + +func (m *monitoringManager) sampleOnce(ctx context.Context, provider MonitoringCapableProvider, cfg connection.ConnectionConfig, session *monitoringSession, generation int64) error { + previous, ok := session.previousPoint(generation) + if !ok { + return nil + } + snapshot, err := provider.GetMonitoringSnapshot(ctx, cfg, previous) + if err != nil { + return err + } + session.applySnapshot(snapshot, generation) + return nil +} + +func (s *monitoringSession) snapshot() MonitoringSessionSnapshot { + s.mu.Lock() + defer s.mu.Unlock() + + return MonitoringSessionSnapshot{ + ConnectionID: s.connectionID, + ProviderMode: s.providerMode, + Running: s.running, + Points: append([]JVMMonitoringPoint(nil), s.points...), + RecentGCEvents: append([]RecentGCEvent(nil), s.recentGCEvents...), + AvailableMetrics: append([]string(nil), s.availableMetrics...), + MissingMetrics: append([]string(nil), s.missingMetrics...), + ProviderWarnings: append([]string(nil), s.providerWarnings...), + } +} + +func (s *monitoringSession) previousPoint(generation int64) (*JVMMonitoringPoint, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + if generation != s.generation { + return nil, false + } + if len(s.points) == 0 { + return nil, true + } + point := s.points[len(s.points)-1] + if point.ThreadStateCounts != nil { + point.ThreadStateCounts = cloneStringIntMap(point.ThreadStateCounts) + } + return &point, true +} + +func (s *monitoringSession) applySnapshot(snapshot JVMMonitoringSnapshot, generation int64) bool { + s.mu.Lock() + defer s.mu.Unlock() + + if generation != s.generation { + return false + } + s.points = append(s.points, cloneMonitoringPoint(snapshot.Point)) + if len(s.points) > s.limit { + s.points = append([]JVMMonitoringPoint(nil), s.points[len(s.points)-s.limit:]...) + } + s.recentGCEvents = append([]RecentGCEvent(nil), snapshot.RecentGCEvents...) + if len(s.recentGCEvents) > defaultMonitoringEventLimit { + s.recentGCEvents = append([]RecentGCEvent(nil), s.recentGCEvents[len(s.recentGCEvents)-defaultMonitoringEventLimit:]...) + } + s.availableMetrics = append([]string(nil), snapshot.AvailableMetrics...) + s.missingMetrics = append([]string(nil), snapshot.MissingMetrics...) + s.providerWarnings = append([]string(nil), snapshot.ProviderWarnings...) + return true +} + +func (s *monitoringSession) appendWarning(warning string) { + s.mu.Lock() + defer s.mu.Unlock() + + trimmed := strings.TrimSpace(warning) + if trimmed == "" { + return + } + for _, existing := range s.providerWarnings { + if existing == trimmed { + return + } + } + s.providerWarnings = append(s.providerWarnings, trimmed) + if len(s.providerWarnings) > defaultMonitoringEventLimit { + s.providerWarnings = append([]string(nil), s.providerWarnings[len(s.providerWarnings)-defaultMonitoringEventLimit:]...) + } +} + +func (s *monitoringSession) reset(connectionID string, providerMode string) int64 { + s.mu.Lock() + defer s.mu.Unlock() + + if s.cancel != nil { + s.cancel() + s.cancel = nil + } + s.generation++ + s.connectionID = connectionID + s.providerMode = providerMode + s.running = false + s.points = nil + s.recentGCEvents = nil + s.availableMetrics = nil + s.missingMetrics = nil + s.providerWarnings = nil + return s.generation +} + +func (s *monitoringSession) setCancel(cancel context.CancelFunc) { + s.mu.Lock() + defer s.mu.Unlock() + s.cancel = cancel +} + +func (s *monitoringSession) markRunning(generation int64) { + s.mu.Lock() + defer s.mu.Unlock() + if generation != s.generation { + return + } + s.running = true +} + +func (s *monitoringSession) markStopped(generation int64) { + s.mu.Lock() + defer s.mu.Unlock() + if generation != s.generation { + return + } + s.running = false + s.cancel = nil +} + +func (s *monitoringSession) stop() { + s.mu.Lock() + cancel := s.cancel + s.cancel = nil + s.generation++ + s.running = false + s.mu.Unlock() + + if cancel != nil { + cancel() + } +} + +func resolveMonitoringConnectionID(cfg connection.ConnectionConfig) string { + if trimmed := strings.TrimSpace(cfg.ID); trimmed != "" { + return trimmed + } + host := strings.TrimSpace(cfg.Host) + if host == "" { + host = "unknown" + } + if cfg.Port > 0 { + return fmt.Sprintf("%s:%d", host, cfg.Port) + } + return host +} + +func cloneMonitoringPoint(point JVMMonitoringPoint) JVMMonitoringPoint { + cloned := point + cloned.ThreadStateCounts = cloneStringIntMap(point.ThreadStateCounts) + return cloned +} + +func cloneStringIntMap(input map[string]int) map[string]int { + if len(input) == 0 { + return nil + } + cloned := make(map[string]int, len(input)) + for key, value := range input { + cloned[key] = value + } + return cloned +} diff --git a/internal/jvm/monitoring_manager_test.go b/internal/jvm/monitoring_manager_test.go new file mode 100644 index 0000000..0555d5b --- /dev/null +++ b/internal/jvm/monitoring_manager_test.go @@ -0,0 +1,353 @@ +package jvm + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "GoNavi-Wails/internal/connection" +) + +type fakeMonitoringProvider struct { + snapshot JVMMonitoringSnapshot + snapshotErr error +} + +type blockingMonitoringProvider struct { + fakeMonitoringProvider + started chan struct{} + release chan struct{} + once sync.Once +} + +func (f fakeMonitoringProvider) Mode() string { return ModeJMX } +func (f fakeMonitoringProvider) TestConnection(context.Context, connection.ConnectionConfig) error { + return nil +} +func (f fakeMonitoringProvider) ProbeCapabilities(context.Context, connection.ConnectionConfig) ([]Capability, error) { + return nil, nil +} +func (f fakeMonitoringProvider) ListResources(context.Context, connection.ConnectionConfig, string) ([]ResourceSummary, error) { + return nil, nil +} +func (f fakeMonitoringProvider) GetValue(context.Context, connection.ConnectionConfig, string) (ValueSnapshot, error) { + return ValueSnapshot{}, nil +} +func (f fakeMonitoringProvider) PreviewChange(context.Context, connection.ConnectionConfig, ChangeRequest) (ChangePreview, error) { + return ChangePreview{}, nil +} +func (f fakeMonitoringProvider) ApplyChange(context.Context, connection.ConnectionConfig, ChangeRequest) (ApplyResult, error) { + return ApplyResult{}, nil +} +func (f fakeMonitoringProvider) GetMonitoringSnapshot(context.Context, connection.ConnectionConfig, *JVMMonitoringPoint) (JVMMonitoringSnapshot, error) { + return f.snapshot, f.snapshotErr +} + +func (p *blockingMonitoringProvider) GetMonitoringSnapshot(context.Context, connection.ConnectionConfig, *JVMMonitoringPoint) (JVMMonitoringSnapshot, error) { + p.once.Do(func() { + close(p.started) + }) + <-p.release + return p.snapshot, p.snapshotErr +} + +func swapMonitoringProviderFactory(factory func(mode string) (Provider, error)) func() { + prev := monitoringProviderFactory + monitoringProviderFactory = factory + return func() { monitoringProviderFactory = prev } +} + +func TestMonitoringRingBufferKeepsLatestPoints(t *testing.T) { + manager := newMonitoringManagerForTest(3) + session := manager.ensureSession("conn-1", ModeJMX) + + for i := 1; i <= 5; i++ { + session.appendPoint(JVMMonitoringPoint{Timestamp: int64(i)}) + } + + snapshot := session.snapshot() + if len(snapshot.Points) != 3 { + t.Fatalf("expected 3 points, got %d", len(snapshot.Points)) + } + if snapshot.Points[0].Timestamp != 3 || snapshot.Points[2].Timestamp != 5 { + t.Fatalf("unexpected points order: %#v", snapshot.Points) + } +} + +func TestMonitoringSessionSnapshotCarriesProviderWarningsAndGCEvents(t *testing.T) { + manager := newMonitoringManagerForTest(5) + session := manager.ensureSession("conn-2", ModeEndpoint) + session.running = true + session.availableMetrics = []string{"heap.used", "thread.count", "memory.rss"} + session.missingMetrics = []string{"cpu.process", "gc.events"} + session.providerWarnings = []string{"endpoint metrics degraded"} + session.recentGCEvents = []RecentGCEvent{ + { + Timestamp: 1713945600000, + Name: "G1 Young Generation", + Cause: "G1 Evacuation Pause", + Action: "end of minor GC", + DurationMs: 21, + BeforeUsedBytes: 734003200, + AfterUsedBytes: 503316480, + }, + } + session.appendPoint(JVMMonitoringPoint{ + Timestamp: 1713945600000, + ThreadCount: 18, + HeapUsedBytes: 503316480, + ProcessRssBytes: 1073741824, + }) + + snapshot := session.snapshot() + if !snapshot.Running { + t.Fatalf("expected session to be running") + } + if snapshot.ProviderMode != ModeEndpoint { + t.Fatalf("expected provider mode %q, got %q", ModeEndpoint, snapshot.ProviderMode) + } + if len(snapshot.AvailableMetrics) != 3 { + t.Fatalf("expected available metrics, got %#v", snapshot.AvailableMetrics) + } + if len(snapshot.MissingMetrics) != 2 || snapshot.MissingMetrics[0] != "cpu.process" { + t.Fatalf("unexpected missing metrics: %#v", snapshot.MissingMetrics) + } + if len(snapshot.ProviderWarnings) != 1 { + t.Fatalf("expected provider warning, got %#v", snapshot.ProviderWarnings) + } + if len(snapshot.RecentGCEvents) != 1 { + t.Fatalf("expected recent gc event, got %#v", snapshot.RecentGCEvents) + } + if len(snapshot.Points) != 1 || snapshot.Points[0].ThreadCount != 18 || snapshot.Points[0].HeapUsedBytes != 503316480 { + t.Fatalf("unexpected points snapshot: %#v", snapshot.Points) + } +} + +func TestMonitoringManagerStartSamplesImmediatelyAndReturnsHistory(t *testing.T) { + manager := newMonitoringManagerForTest(5) + restore := swapMonitoringProviderFactory(func(mode string) (Provider, error) { + return fakeMonitoringProvider{ + snapshot: JVMMonitoringSnapshot{ + Point: JVMMonitoringPoint{ + Timestamp: 1713945600000, + ThreadCount: 12, + HeapUsedBytes: 268435456, + ProcessCpuLoad: 0.42, + }, + AvailableMetrics: []string{"thread.count", "heap.used"}, + MissingMetrics: []string{"cpu.process"}, + ProviderWarnings: []string{"jmx cpu metric unavailable"}, + }, + }, nil + }) + defer restore() + + readOnly := true + cfg := connection.ConnectionConfig{ + ID: "conn-monitor", + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + PreferredMode: ModeJMX, + AllowedModes: []string{ModeJMX}, + }, + } + + snapshot, err := manager.Start(context.Background(), cfg, "") + if err != nil { + t.Fatalf("Start returned error: %v", err) + } + if !snapshot.Running { + t.Fatalf("expected started session to be running") + } + if len(snapshot.Points) != 1 || snapshot.Points[0].ThreadCount != 12 || snapshot.Points[0].HeapUsedBytes != 268435456 { + t.Fatalf("unexpected initial points: %#v", snapshot.Points) + } + + history, err := manager.GetHistory("conn-monitor", ModeJMX) + if err != nil { + t.Fatalf("GetHistory returned error: %v", err) + } + if len(history.MissingMetrics) != 1 || history.MissingMetrics[0] != "cpu.process" { + t.Fatalf("unexpected history missing metrics: %#v", history.MissingMetrics) + } + if len(history.ProviderWarnings) != 1 { + t.Fatalf("unexpected provider warnings: %#v", history.ProviderWarnings) + } +} + +func TestMonitoringManagerStopMarksSessionStopped(t *testing.T) { + manager := newMonitoringManagerForTest(5) + restore := swapMonitoringProviderFactory(func(mode string) (Provider, error) { + return fakeMonitoringProvider{ + snapshot: JVMMonitoringSnapshot{ + Point: JVMMonitoringPoint{Timestamp: 1713945600000, ThreadCount: 7}, + }, + }, nil + }) + defer restore() + + cfg := connection.ConnectionConfig{ + ID: "conn-stop", + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: ModeEndpoint, + AllowedModes: []string{ModeEndpoint}, + }, + } + + if _, err := manager.Start(context.Background(), cfg, ModeEndpoint); err != nil { + t.Fatalf("Start returned error: %v", err) + } + if err := manager.Stop("conn-stop", ModeEndpoint); err != nil { + t.Fatalf("Stop returned error: %v", err) + } + + history, err := manager.GetHistory("conn-stop", ModeEndpoint) + if err != nil { + t.Fatalf("GetHistory returned error: %v", err) + } + if history.Running { + t.Fatalf("expected session to stop running, got %#v", history) + } +} + +func TestMonitoringSessionIgnoresStaleStopFromPreviousSampler(t *testing.T) { + session := &monitoringSession{} + + firstGeneration := session.reset("conn-race", ModeJMX) + session.markRunning(firstGeneration) + secondGeneration := session.reset("conn-race", ModeJMX) + session.markRunning(secondGeneration) + + session.markStopped(firstGeneration) + if snapshot := session.snapshot(); !snapshot.Running { + t.Fatalf("expected stale sampler stop to be ignored, got %#v", snapshot) + } + + session.markStopped(secondGeneration) + if snapshot := session.snapshot(); snapshot.Running { + t.Fatalf("expected active generation stop to mark stopped, got %#v", snapshot) + } +} + +func TestMonitoringSessionIgnoresStalePointFromPreviousSampler(t *testing.T) { + manager := newMonitoringManager(5, time.Millisecond) + session := &monitoringSession{limit: 5} + provider := &blockingMonitoringProvider{ + fakeMonitoringProvider: fakeMonitoringProvider{ + snapshot: JVMMonitoringSnapshot{ + Point: JVMMonitoringPoint{ + Timestamp: 1713945600000, + ThreadCount: 8, + }, + AvailableMetrics: []string{"thread.count"}, + }, + }, + started: make(chan struct{}), + release: make(chan struct{}), + } + + firstGeneration := session.reset("conn-race", ModeJMX) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + manager.runSampler(ctx, provider, connection.ConnectionConfig{}, session, firstGeneration) + close(done) + }() + + select { + case <-provider.started: + case <-time.After(time.Second): + t.Fatal("sampler did not start within 1s") + } + + secondGeneration := session.reset("conn-race", ModeJMX) + session.markRunning(secondGeneration) + close(provider.release) + cancel() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("sampler did not stop within 1s") + } + + snapshot := session.snapshot() + if !snapshot.Running { + t.Fatalf("expected new generation to remain running, got %#v", snapshot) + } + if len(snapshot.Points) != 0 { + t.Fatalf("expected stale sampler point to be ignored, got %#v", snapshot.Points) + } +} + +func TestFinalizeMonitoringSnapshotPreservesProviderDeltaWhenClassTotalMissing(t *testing.T) { + snapshot := JVMMonitoringSnapshot{ + Point: JVMMonitoringPoint{ + Timestamp: 1713945602000, + ClassLoadDelta: 3, + }, + AvailableMetrics: []string{"class.delta"}, + } + + finalizeMonitoringSnapshot(&snapshot, &JVMMonitoringPoint{ + Timestamp: 1713945600000, + LoadedClassCount: 200, + }) + + if snapshot.Point.ClassLoadDelta != 3 { + t.Fatalf("expected provider class delta to be preserved, got %#v", snapshot.Point) + } +} + +func TestMonitoringSamplerStopsAfterConsecutiveFailures(t *testing.T) { + manager := newMonitoringManager(5, time.Millisecond) + session := &monitoringSession{limit: 5} + generation := session.reset("conn-fail", ModeJMX) + session.markRunning(generation) + provider := fakeMonitoringProvider{snapshotErr: errors.New("collector unavailable")} + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan struct{}) + go func() { + manager.runSampler(ctx, provider, connection.ConnectionConfig{}, session, generation) + close(done) + }() + + deadline := time.After(time.Second) + for { + select { + case <-done: + snapshot := session.snapshot() + if snapshot.Running { + t.Fatalf("expected session to stop after consecutive failures, got %#v", snapshot) + } + if len(snapshot.ProviderWarnings) == 0 { + t.Fatalf("expected provider warnings to explain sampling failure") + } + return + case <-deadline: + t.Fatal("sampler did not stop after consecutive failures") + case <-time.After(10 * time.Millisecond): + } + } +} + +func TestMonitoringSessionDeduplicatesProviderWarnings(t *testing.T) { + session := &monitoringSession{} + + session.appendWarning("collector unavailable") + session.appendWarning("collector unavailable") + session.appendWarning(" collector unavailable ") + + snapshot := session.snapshot() + if len(snapshot.ProviderWarnings) != 1 { + t.Fatalf("expected duplicate provider warnings to be collapsed, got %#v", snapshot.ProviderWarnings) + } +} diff --git a/internal/jvm/monitoring_types.go b/internal/jvm/monitoring_types.go new file mode 100644 index 0000000..e0323ea --- /dev/null +++ b/internal/jvm/monitoring_types.go @@ -0,0 +1,90 @@ +package jvm + +import ( + "context" + + "GoNavi-Wails/internal/connection" +) + +type JVMMonitoringPoint struct { + Timestamp int64 `json:"timestamp"` + HeapUsedBytes int64 `json:"heapUsedBytes,omitempty"` + HeapCommittedBytes int64 `json:"heapCommittedBytes,omitempty"` + HeapMaxBytes int64 `json:"heapMaxBytes,omitempty"` + NonHeapUsedBytes int64 `json:"nonHeapUsedBytes,omitempty"` + NonHeapCommittedBytes int64 `json:"nonHeapCommittedBytes,omitempty"` + GCCollectionCount int64 `json:"gcCollectionCount,omitempty"` + GCCollectionTimeMs int64 `json:"gcCollectionTimeMs,omitempty"` + GCDeltaCount int64 `json:"gcDeltaCount,omitempty"` + GCDeltaTimeMs int64 `json:"gcDeltaTimeMs,omitempty"` + ThreadCount int `json:"threadCount,omitempty"` + DaemonThreadCount int `json:"daemonThreadCount,omitempty"` + PeakThreadCount int `json:"peakThreadCount,omitempty"` + ThreadStateCounts map[string]int `json:"threadStateCounts,omitempty"` + LoadedClassCount int `json:"loadedClassCount,omitempty"` + UnloadedClassCount int64 `json:"unloadedClassCount,omitempty"` + ClassLoadDelta int64 `json:"classLoadDelta,omitempty"` + ProcessCpuLoad float64 `json:"processCpuLoad,omitempty"` + SystemCpuLoad float64 `json:"systemCpuLoad,omitempty"` + ProcessRssBytes int64 `json:"processRssBytes,omitempty"` + CommittedVirtualMemoryBytes int64 `json:"committedVirtualMemoryBytes,omitempty"` +} + +type RecentGCEvent struct { + Timestamp int64 `json:"timestamp"` + Name string `json:"name,omitempty"` + Cause string `json:"cause,omitempty"` + Action string `json:"action,omitempty"` + DurationMs int64 `json:"durationMs,omitempty"` + BeforeUsedBytes int64 `json:"beforeUsedBytes,omitempty"` + AfterUsedBytes int64 `json:"afterUsedBytes,omitempty"` +} + +type MonitoringSessionSnapshot struct { + ConnectionID string `json:"connectionId"` + ProviderMode string `json:"providerMode"` + Running bool `json:"running"` + Points []JVMMonitoringPoint `json:"points,omitempty"` + RecentGCEvents []RecentGCEvent `json:"recentGcEvents,omitempty"` + AvailableMetrics []string `json:"availableMetrics,omitempty"` + MissingMetrics []string `json:"missingMetrics,omitempty"` + ProviderWarnings []string `json:"providerWarnings,omitempty"` +} + +type JVMMonitoringSnapshot struct { + Point JVMMonitoringPoint `json:"point"` + RecentGCEvents []RecentGCEvent `json:"recentGcEvents,omitempty"` + AvailableMetrics []string `json:"availableMetrics,omitempty"` + MissingMetrics []string `json:"missingMetrics,omitempty"` + ProviderWarnings []string `json:"providerWarnings,omitempty"` +} + +type MonitoringCapableProvider interface { + Provider + GetMonitoringSnapshot(ctx context.Context, cfg connection.ConnectionConfig, previous *JVMMonitoringPoint) (JVMMonitoringSnapshot, error) +} + +func finalizeMonitoringSnapshot(snapshot *JVMMonitoringSnapshot, previous *JVMMonitoringPoint) { + if snapshot == nil || previous == nil { + return + } + + if hasMonitoringMetric(snapshot.AvailableMetrics, "gc.count") && snapshot.Point.GCCollectionCount >= previous.GCCollectionCount { + snapshot.Point.GCDeltaCount = snapshot.Point.GCCollectionCount - previous.GCCollectionCount + } + if hasMonitoringMetric(snapshot.AvailableMetrics, "gc.time") && snapshot.Point.GCCollectionTimeMs >= previous.GCCollectionTimeMs { + snapshot.Point.GCDeltaTimeMs = snapshot.Point.GCCollectionTimeMs - previous.GCCollectionTimeMs + } + if hasMonitoringMetric(snapshot.AvailableMetrics, "class.loading") { + snapshot.Point.ClassLoadDelta = int64(snapshot.Point.LoadedClassCount) - int64(previous.LoadedClassCount) + } +} + +func hasMonitoringMetric(metrics []string, expected string) bool { + for _, metric := range metrics { + if metric == expected { + return true + } + } + return false +} diff --git a/tools/jmx-helper/src/com/gonavi/jmxhelper/JmxRuntime.java b/tools/jmx-helper/src/com/gonavi/jmxhelper/JmxRuntime.java index 7124b73..46e1540 100644 --- a/tools/jmx-helper/src/com/gonavi/jmxhelper/JmxRuntime.java +++ b/tools/jmx-helper/src/com/gonavi/jmxhelper/JmxRuntime.java @@ -1,5 +1,12 @@ package com.gonavi.jmxhelper; +import java.lang.management.ClassLoadingMXBean; +import java.lang.management.GarbageCollectorMXBean; +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryMXBean; +import java.lang.management.MemoryUsage; +import java.lang.management.ThreadInfo; +import java.lang.management.ThreadMXBean; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -46,6 +53,8 @@ final class JmxRuntime { return listResources(server, connection, target); case "get": return singleton("snapshot", getValue(server, target)); + case "monitor": + return singleton("monitoringSnapshot", getMonitoringSnapshot(server)); case "preview": return singleton("preview", previewChange(server, target, change)); case "apply": @@ -210,6 +219,208 @@ final class JmxRuntime { throw new IllegalArgumentException("unsupported target kind: " + target.kind); } + private static Map getMonitoringSnapshot(MBeanServerConnection server) throws Exception { + LinkedHashMap result = new LinkedHashMap<>(); + LinkedHashMap point = new LinkedHashMap<>(); + List availableMetrics = new ArrayList<>(); + List missingMetrics = new ArrayList<>(); + List providerWarnings = new ArrayList<>(); + + long sampleTimestamp = System.currentTimeMillis(); + point.put("timestamp", sampleTimestamp); + + try { + ThreadMXBean threadBean = ManagementFactory.newPlatformMXBeanProxy( + server, + ManagementFactory.THREAD_MXBEAN_NAME, + ThreadMXBean.class + ); + point.put("threadCount", threadBean.getThreadCount()); + point.put("daemonThreadCount", threadBean.getDaemonThreadCount()); + point.put("peakThreadCount", threadBean.getPeakThreadCount()); + addUnique(availableMetrics, "thread.count"); + + long[] threadIds = threadBean.getAllThreadIds(); + ThreadInfo[] infos = threadBean.getThreadInfo(threadIds, 0); + Map stateCounts = new LinkedHashMap<>(); + for (ThreadInfo info : infos) { + if (info == null || info.getThreadState() == null) { + continue; + } + String state = info.getThreadState().name(); + int current = stateCounts.get(state) instanceof Number + ? ((Number) stateCounts.get(state)).intValue() + : 0; + stateCounts.put(state, current + 1); + } + if (!stateCounts.isEmpty()) { + point.put("threadStateCounts", stateCounts); + addUnique(availableMetrics, "thread.states"); + } + } catch (Exception error) { + addUnique(missingMetrics, "thread.count"); + addUnique(providerWarnings, "thread metrics unavailable: " + error.getMessage()); + } + + try { + MemoryMXBean memoryBean = ManagementFactory.newPlatformMXBeanProxy( + server, + ManagementFactory.MEMORY_MXBEAN_NAME, + MemoryMXBean.class + ); + MemoryUsage heap = memoryBean.getHeapMemoryUsage(); + if (heap != null) { + point.put("heapUsedBytes", heap.getUsed()); + point.put("heapCommittedBytes", heap.getCommitted()); + point.put("heapMaxBytes", heap.getMax()); + addUnique(availableMetrics, "heap.used"); + } else { + addUnique(missingMetrics, "heap.used"); + } + + MemoryUsage nonHeap = memoryBean.getNonHeapMemoryUsage(); + if (nonHeap != null) { + point.put("nonHeapUsedBytes", nonHeap.getUsed()); + point.put("nonHeapCommittedBytes", nonHeap.getCommitted()); + addUnique(availableMetrics, "heap.non_heap"); + } + } catch (Exception error) { + addUnique(missingMetrics, "heap.used"); + addUnique(providerWarnings, "heap metrics unavailable: " + error.getMessage()); + } + + try { + ClassLoadingMXBean classLoadingBean = ManagementFactory.newPlatformMXBeanProxy( + server, + ManagementFactory.CLASS_LOADING_MXBEAN_NAME, + ClassLoadingMXBean.class + ); + point.put("loadedClassCount", classLoadingBean.getLoadedClassCount()); + point.put("unloadedClassCount", classLoadingBean.getUnloadedClassCount()); + addUnique(availableMetrics, "class.loading"); + } catch (Exception error) { + addUnique(missingMetrics, "class.loading"); + addUnique(providerWarnings, "class loading metrics unavailable: " + error.getMessage()); + } + + try { + List> recentGcEvents = new ArrayList<>(); + long totalCount = 0L; + long totalTime = 0L; + Set names = server.queryNames( + new ObjectName(ManagementFactory.GARBAGE_COLLECTOR_MXBEAN_DOMAIN_TYPE + ",*"), + null + ); + for (ObjectName name : names) { + Long collectionCount = safeLongAttribute(server, name, "CollectionCount"); + if (collectionCount != null && collectionCount >= 0L) { + totalCount += collectionCount.longValue(); + } + Long collectionTime = safeLongAttribute(server, name, "CollectionTime"); + if (collectionTime != null && collectionTime >= 0L) { + totalTime += collectionTime.longValue(); + } + + Object lastGcInfo = safeAttribute(server, name, "LastGcInfo"); + if (lastGcInfo instanceof CompositeData) { + CompositeData data = (CompositeData) lastGcInfo; + LinkedHashMap event = new LinkedHashMap<>(); + event.put("timestamp", sampleTimestamp); + event.put("name", name.getKeyProperty("name")); + Object gcCause = compositeValue(data, "GcCause"); + if (gcCause != null) { + event.put("cause", String.valueOf(gcCause)); + } + Object gcAction = compositeValue(data, "GcAction"); + if (gcAction != null) { + event.put("action", String.valueOf(gcAction)); + } + Object duration = compositeValue(data, "duration"); + if (duration instanceof Number) { + event.put("durationMs", ((Number) duration).longValue()); + } + long beforeUsedBytes = sumMemoryUsage(compositeValue(data, "memoryUsageBeforeGc")); + if (beforeUsedBytes > 0L) { + event.put("beforeUsedBytes", beforeUsedBytes); + } + long afterUsedBytes = sumMemoryUsage(compositeValue(data, "memoryUsageAfterGc")); + if (afterUsedBytes > 0L) { + event.put("afterUsedBytes", afterUsedBytes); + } + recentGcEvents.add(event); + } + } + point.put("gcCollectionCount", totalCount); + point.put("gcCollectionTimeMs", totalTime); + result.put("recentGcEvents", recentGcEvents); + addUnique(availableMetrics, "gc.count"); + addUnique(availableMetrics, "gc.time"); + if (!recentGcEvents.isEmpty()) { + addUnique(availableMetrics, "gc.events"); + } else { + addUnique(missingMetrics, "gc.events"); + } + } catch (Exception error) { + addUnique(missingMetrics, "gc.count"); + addUnique(missingMetrics, "gc.time"); + addUnique(missingMetrics, "gc.events"); + addUnique(providerWarnings, "gc metrics unavailable: " + error.getMessage()); + } + + try { + ObjectName osName = new ObjectName("java.lang:type=OperatingSystem"); + Double processCpuLoad = safeDoubleAttribute(server, osName, "ProcessCpuLoad"); + if (processCpuLoad != null && processCpuLoad >= 0d) { + point.put("processCpuLoad", processCpuLoad.doubleValue()); + addUnique(availableMetrics, "cpu.process"); + } else { + addUnique(missingMetrics, "cpu.process"); + } + + Double systemCpuLoad = safeDoubleAttribute(server, osName, "SystemCpuLoad"); + if (systemCpuLoad != null && systemCpuLoad >= 0d) { + point.put("systemCpuLoad", systemCpuLoad.doubleValue()); + addUnique(availableMetrics, "cpu.system"); + } else { + addUnique(missingMetrics, "cpu.system"); + } + + Long processRssBytes = firstNumericAttribute( + server, + osName, + "ProcessResidentMemorySize", + "ResidentSetSize", + "ResidentMemorySize" + ); + if (processRssBytes != null && processRssBytes >= 0L) { + point.put("processRssBytes", processRssBytes.longValue()); + addUnique(availableMetrics, "memory.rss"); + } else { + addUnique(missingMetrics, "memory.rss"); + } + + Long committedVirtualMemoryBytes = safeLongAttribute(server, osName, "CommittedVirtualMemorySize"); + if (committedVirtualMemoryBytes != null && committedVirtualMemoryBytes >= 0L) { + point.put("committedVirtualMemoryBytes", committedVirtualMemoryBytes.longValue()); + addUnique(availableMetrics, "memory.virtual"); + } else { + addUnique(missingMetrics, "memory.virtual"); + } + } catch (Exception error) { + addUnique(missingMetrics, "cpu.process"); + addUnique(missingMetrics, "cpu.system"); + addUnique(missingMetrics, "memory.rss"); + addUnique(missingMetrics, "memory.virtual"); + addUnique(providerWarnings, "process/system metrics unavailable: " + error.getMessage()); + } + + result.put("point", point); + result.put("availableMetrics", availableMetrics); + result.put("missingMetrics", missingMetrics); + result.put("providerWarnings", providerWarnings); + return result; + } + private static Map previewChange( MBeanServerConnection server, TargetSpec target, @@ -858,6 +1069,82 @@ final class JmxRuntime { return result; } + private static void addUnique(List items, String value) { + if (value == null || value.isEmpty() || items.contains(value)) { + return; + } + items.add(value); + } + + private static Object safeAttribute(MBeanServerConnection server, ObjectName objectName, String attribute) { + try { + return server.getAttribute(objectName, attribute); + } catch (Exception error) { + return null; + } + } + + private static Long safeLongAttribute(MBeanServerConnection server, ObjectName objectName, String attribute) { + Object value = safeAttribute(server, objectName, attribute); + return value instanceof Number ? ((Number) value).longValue() : null; + } + + private static Double safeDoubleAttribute(MBeanServerConnection server, ObjectName objectName, String attribute) { + Object value = safeAttribute(server, objectName, attribute); + return value instanceof Number ? ((Number) value).doubleValue() : null; + } + + private static Long firstNumericAttribute( + MBeanServerConnection server, + ObjectName objectName, + String... attributeNames + ) { + for (String attributeName : attributeNames) { + Long value = safeLongAttribute(server, objectName, attributeName); + if (value != null) { + return value; + } + } + return null; + } + + private static Object compositeValue(CompositeData data, String key) { + if (data == null || key == null || !data.getCompositeType().containsKey(key)) { + return null; + } + return data.get(key); + } + + private static long sumMemoryUsage(Object value) { + long total = 0L; + if (value instanceof TabularData) { + for (Object item : ((TabularData) value).values()) { + total += usedFromMemoryUsage(item); + } + return total; + } + if (value instanceof Map) { + for (Object item : ((Map) value).values()) { + total += usedFromMemoryUsage(item); + } + return total; + } + return usedFromMemoryUsage(value); + } + + private static long usedFromMemoryUsage(Object value) { + if (!(value instanceof CompositeData)) { + return 0L; + } + CompositeData data = (CompositeData) value; + Object used = compositeValue(data, "used"); + if (used instanceof Number) { + return ((Number) used).longValue(); + } + Object nestedValue = compositeValue(data, "value"); + return usedFromMemoryUsage(nestedValue); + } + @SuppressWarnings("unchecked") private static Map requiredObject(Object value, String label) { if (value instanceof Map) {