This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch feature/CAMEL-23870-heap-histogram in repository https://gitbox.apache.org/repos/asf/camel.git
commit ea88102e3928620171e06be34d6d840ccd597ec5 Author: Claus Ibsen <[email protected]> AuthorDate: Wed Jul 1 16:01:16 2026 +0200 CAMEL-23870: Add HeapHistogram dev console and TUI panel Co-Authored-By: Claude Opus 4.6 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../console/HeapHistogramDevConsoleConfigurer.java | 63 +++ .../apache/camel/dev-console/heap-histogram.json | 15 + ...ache.camel.impl.console.HeapHistogramDevConsole | 2 + .../org/apache/camel/dev-console/heap-histogram | 2 + .../org/apache/camel/dev-consoles.properties | 2 +- .../impl/console/HeapHistogramDevConsole.java | 178 ++++++++ .../camel/cli/connector/LocalCliConnector.java | 14 + .../jbang/core/commands/tui/HeapHistogramTab.java | 471 +++++++++++++++++++++ .../dsl/jbang/core/commands/tui/McpFacade.java | 6 +- .../dsl/jbang/core/commands/tui/PopupManager.java | 26 +- .../dsl/jbang/core/commands/tui/TabRegistry.java | 24 +- 11 files changed, 779 insertions(+), 24 deletions(-) diff --git a/core/camel-console/src/generated/java/org/apache/camel/impl/console/HeapHistogramDevConsoleConfigurer.java b/core/camel-console/src/generated/java/org/apache/camel/impl/console/HeapHistogramDevConsoleConfigurer.java new file mode 100644 index 000000000000..707bd8e5f6c7 --- /dev/null +++ b/core/camel-console/src/generated/java/org/apache/camel/impl/console/HeapHistogramDevConsoleConfigurer.java @@ -0,0 +1,63 @@ +/* Generated by camel build tools - do NOT edit this file! */ +package org.apache.camel.impl.console; + +import javax.annotation.processing.Generated; +import java.util.Map; + +import org.apache.camel.CamelContext; +import org.apache.camel.spi.ExtendedPropertyConfigurerGetter; +import org.apache.camel.spi.PropertyConfigurerGetter; +import org.apache.camel.spi.ConfigurerStrategy; +import org.apache.camel.spi.GeneratedPropertyConfigurer; +import org.apache.camel.util.CaseInsensitiveMap; +import org.apache.camel.impl.console.HeapHistogramDevConsole; + +/** + * Generated by camel build tools - do NOT edit this file! + */ +@Generated("org.apache.camel.maven.packaging.GenerateConfigurerMojo") +@SuppressWarnings("unchecked") +public class HeapHistogramDevConsoleConfigurer extends org.apache.camel.support.component.PropertyConfigurerSupport implements GeneratedPropertyConfigurer, ExtendedPropertyConfigurerGetter { + + private static final Map<String, Object> ALL_OPTIONS; + static { + Map<String, Object> map = new CaseInsensitiveMap(); + map.put("CamelContext", org.apache.camel.CamelContext.class); + ALL_OPTIONS = map; + } + + @Override + public boolean configure(CamelContext camelContext, Object obj, String name, Object value, boolean ignoreCase) { + org.apache.camel.impl.console.HeapHistogramDevConsole target = (org.apache.camel.impl.console.HeapHistogramDevConsole) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "camelcontext": + case "camelContext": target.setCamelContext(property(camelContext, org.apache.camel.CamelContext.class, value)); return true; + default: return false; + } + } + + @Override + public Map<String, Object> getAllOptions(Object target) { + return ALL_OPTIONS; + } + + @Override + public Class<?> getOptionType(String name, boolean ignoreCase) { + switch (ignoreCase ? name.toLowerCase() : name) { + case "camelcontext": + case "camelContext": return org.apache.camel.CamelContext.class; + default: return null; + } + } + + @Override + public Object getOptionValue(Object obj, String name, boolean ignoreCase) { + org.apache.camel.impl.console.HeapHistogramDevConsole target = (org.apache.camel.impl.console.HeapHistogramDevConsole) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "camelcontext": + case "camelContext": return target.getCamelContext(); + default: return null; + } + } +} + diff --git a/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/heap-histogram.json b/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/heap-histogram.json new file mode 100644 index 000000000000..af0809c2bbf5 --- /dev/null +++ b/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/heap-histogram.json @@ -0,0 +1,15 @@ +{ + "console": { + "kind": "console", + "group": "camel", + "name": "heap-histogram", + "title": "Heap Histogram", + "description": "Displays class-level heap memory usage", + "deprecated": false, + "javaType": "org.apache.camel.impl.console.HeapHistogramDevConsole", + "groupId": "org.apache.camel", + "artifactId": "camel-console", + "version": "4.21.0-SNAPSHOT" + } +} + diff --git a/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.impl.console.HeapHistogramDevConsole b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.impl.console.HeapHistogramDevConsole new file mode 100644 index 000000000000..6059841ac7de --- /dev/null +++ b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.impl.console.HeapHistogramDevConsole @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.impl.console.HeapHistogramDevConsoleConfigurer diff --git a/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-console/heap-histogram b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-console/heap-histogram new file mode 100644 index 000000000000..2254ff32042b --- /dev/null +++ b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-console/heap-histogram @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.impl.console.HeapHistogramDevConsole diff --git a/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties index fe5d76fe5112..c429fc6982f4 100644 --- a/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties +++ b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties @@ -1,5 +1,5 @@ # Generated by camel build tools - do NOT edit this file! -dev-consoles=bean blocked browse circuit-breaker consumer context datasource debug endpoint errors eval-language event gc health inflight internal-tasks java-security jvm log memory message-history processor producer properties receive reload rest rest-spec route route-controller route-dump route-group route-structure route-topology send service simple-language source sql-query sql-trace startup-recorder system-properties thread top trace transformers type-converters variables +dev-consoles=bean blocked browse circuit-breaker consumer context datasource debug endpoint errors eval-language event gc health heap-histogram inflight internal-tasks java-security jvm log memory message-history processor producer properties receive reload rest rest-spec route route-controller route-dump route-group route-structure route-topology send service simple-language source sql-query sql-trace startup-recorder system-properties thread top trace transformers type-converters variables groupId=org.apache.camel artifactId=camel-console version=4.21.0-SNAPSHOT diff --git a/core/camel-console/src/main/java/org/apache/camel/impl/console/HeapHistogramDevConsole.java b/core/camel-console/src/main/java/org/apache/camel/impl/console/HeapHistogramDevConsole.java new file mode 100644 index 000000000000..e07a5f0b1a97 --- /dev/null +++ b/core/camel-console/src/main/java/org/apache/camel/impl/console/HeapHistogramDevConsole.java @@ -0,0 +1,178 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.impl.console; + +import java.lang.management.ManagementFactory; +import java.util.Map; + +import javax.management.MBeanServer; +import javax.management.ObjectName; + +import org.apache.camel.spi.Configurer; +import org.apache.camel.spi.annotations.DevConsole; +import org.apache.camel.support.console.AbstractDevConsole; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; + +@DevConsole(name = "heap-histogram", displayName = "Heap Histogram", description = "Displays class-level heap memory usage") +@Configurer(extended = true) +public class HeapHistogramDevConsole extends AbstractDevConsole { + + public HeapHistogramDevConsole() { + super("jvm", "heap-histogram", "Heap Histogram", "Displays class-level heap memory usage"); + } + + @Override + protected String doCallText(Map<String, Object> options) { + String histogram = invokeGcClassHistogram(); + if (histogram == null) { + return "Heap histogram not available (DiagnosticCommand MBean not found)"; + } + int limit = getLimit(options); + if (limit > 0) { + return truncateText(histogram, limit); + } + return histogram; + } + + @Override + protected JsonObject doCallJson(Map<String, Object> options) { + JsonObject root = new JsonObject(); + + String histogram = invokeGcClassHistogram(); + if (histogram == null) { + root.put("classes", new JsonArray()); + root.put("totalInstances", 0); + root.put("totalBytes", 0); + return root; + } + + int limit = getLimit(options); + return parseHistogram(histogram, limit); + } + + private static int getLimit(Map<String, Object> options) { + Object val = options.get("limit"); + if (val != null) { + try { + return Integer.parseInt(val.toString()); + } catch (NumberFormatException e) { + // use default + } + } + return 100; + } + + private static String invokeGcClassHistogram() { + try { + MBeanServer server = ManagementFactory.getPlatformMBeanServer(); + ObjectName name = new ObjectName("com.sun.management:type=DiagnosticCommand"); + String result = (String) server.invoke( + name, "gcClassHistogram", + new Object[] { null }, + new String[] { String[].class.getName() }); + return result; + } catch (Exception e) { + return null; + } + } + + private static JsonObject parseHistogram(String histogram, int limit) { + JsonObject root = new JsonObject(); + JsonArray arr = new JsonArray(); + root.put("classes", arr); + + long totalInstances = 0; + long totalBytes = 0; + int count = 0; + + String[] lines = histogram.split("\n"); + for (String line : lines) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("-") || line.startsWith("num")) { + continue; + } + if (line.startsWith("Total")) { + String[] parts = line.split("\\s+"); + if (parts.length >= 3) { + totalInstances = parseLong(parts[1]); + totalBytes = parseLong(parts[2]); + } + continue; + } + + String[] parts = line.split("\\s+"); + if (parts.length < 4) { + continue; + } + + // parts[0]=num: parts[1]=#instances parts[2]=#bytes parts[3]=class + String numStr = parts[0].replace(":", ""); + int num = (int) parseLong(numStr); + long instances = parseLong(parts[1]); + long bytes = parseLong(parts[2]); + String className = parts[3]; + + if (limit > 0 && count >= limit) { + break; + } + + JsonObject jo = new JsonObject(); + jo.put("num", num); + jo.put("instances", instances); + jo.put("bytes", bytes); + jo.put("className", className); + arr.add(jo); + count++; + } + + root.put("totalInstances", totalInstances); + root.put("totalBytes", totalBytes); + + return root; + } + + private static long parseLong(String s) { + try { + return Long.parseLong(s); + } catch (NumberFormatException e) { + return 0; + } + } + + private static String truncateText(String histogram, int limit) { + StringBuilder sb = new StringBuilder(); + int count = 0; + String[] lines = histogram.split("\n"); + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("-") || trimmed.startsWith("num")) { + sb.append(line).append('\n'); + continue; + } + if (trimmed.startsWith("Total")) { + sb.append(line).append('\n'); + continue; + } + if (count < limit) { + sb.append(line).append('\n'); + count++; + } + } + return sb.toString(); + } +} diff --git a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java index d8d562fb77a9..2e336aec1cf7 100644 --- a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java +++ b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java @@ -335,6 +335,8 @@ public class LocalCliConnector extends ServiceSupport implements CliConnector, C doActionJvmTask(); } else if ("thread-dump".equals(action)) { doActionThreadDumpTask(); + } else if ("heap-histogram".equals(action)) { + doActionHeapHistogramTask(); } else if ("top-processors".equals(action)) { doActionTopProcessorsTask(); } else if ("source".equals(action)) { @@ -836,6 +838,18 @@ public class LocalCliConnector extends ServiceSupport implements CliConnector, C } } + private void doActionHeapHistogramTask() throws IOException { + DevConsole dc = camelContext.getCamelContextExtension().getContextPlugin(DevConsoleRegistry.class) + .resolveById("heap-histogram"); + if (dc != null) { + JsonObject json = (JsonObject) dc.call(DevConsole.MediaType.JSON); + LOG.trace("Updating output file: {}", outputFile); + IOHelper.writeText(json.toJson(), outputFile); + } else { + IOHelper.writeText("{}", outputFile); + } + } + private void doActionKafkaTask() throws IOException { DevConsole dc = camelContext.getCamelContextExtension().getContextPlugin(DevConsoleRegistry.class) .resolveById("kafka"); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HeapHistogramTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HeapHistogramTab.java new file mode 100644 index 000000000000..3fe2004ce698 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HeapHistogramTab.java @@ -0,0 +1,471 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import dev.tamboui.layout.Constraint; +import dev.tamboui.layout.Layout; +import dev.tamboui.layout.Rect; +import dev.tamboui.style.Color; +import dev.tamboui.style.Style; +import dev.tamboui.terminal.Frame; +import dev.tamboui.text.Line; +import dev.tamboui.text.Span; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.widgets.block.Block; +import dev.tamboui.widgets.block.BorderType; +import dev.tamboui.widgets.block.Borders; +import dev.tamboui.widgets.paragraph.Paragraph; +import dev.tamboui.widgets.table.Cell; +import dev.tamboui.widgets.table.Row; +import dev.tamboui.widgets.table.Table; +import dev.tamboui.widgets.table.TableState; +import org.apache.camel.dsl.jbang.core.common.PathUtils; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; + +import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.*; + +class HeapHistogramTab implements MonitorTab { + + private static final String[] SORT_COLUMNS = { "className", "instances", "bytes" }; + private static final String[] FILTER_LABELS = { "all", "non-jdk", "camel" }; + + private final MonitorContext ctx; + private final TableState tableState = new TableState(); + private final AtomicBoolean loading = new AtomicBoolean(false); + + private String sort = "bytes"; + private int sortIndex = 2; + private boolean sortReversed; + private int filter; // 0=all, 1=camel + private List<HeapEntry> allEntries = Collections.emptyList(); + private long totalInstances; + private long totalBytes; + private String lastPid; + + HeapHistogramTab(MonitorContext ctx) { + this.ctx = ctx; + } + + @Override + public void onTabSelected() { + String pid = ctx.selectedPid; + if (pid != null && !pid.equals(lastPid)) { + lastPid = pid; + allEntries = Collections.emptyList(); + } + if (allEntries.isEmpty()) { + loadHeapHistogram(); + } + } + + @Override + public void onIntegrationChanged() { + allEntries = Collections.emptyList(); + lastPid = null; + loadHeapHistogram(); + } + + @Override + public boolean handleKeyEvent(KeyEvent ke) { + if (ke.isChar('s')) { + sortIndex = (sortIndex + 1) % SORT_COLUMNS.length; + sort = SORT_COLUMNS[sortIndex]; + sortReversed = false; + return true; + } + if (ke.isChar('S')) { + sortReversed = !sortReversed; + return true; + } + if (ke.isCharIgnoreCase('f')) { + filter = (filter + 1) % FILTER_LABELS.length; + return true; + } + if (ke.isKey(KeyCode.F5)) { + loadHeapHistogram(); + return true; + } + if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) { + for (int i = 0; i < 20 && tableState.selected() != null && tableState.selected() > 0; i++) { + tableState.selectPrevious(); + } + return true; + } + if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) { + List<HeapEntry> visible = sortedEntries(); + for (int i = 0; i < 20; i++) { + tableState.selectNext(visible.size()); + } + return true; + } + return false; + } + + @Override + public boolean handleEscape() { + return false; + } + + @Override + public void navigateUp() { + tableState.selectPrevious(); + } + + @Override + public void navigateDown() { + List<HeapEntry> visible = sortedEntries(); + tableState.selectNext(visible.size()); + } + + @Override + public void render(Frame frame, Rect area) { + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info == null) { + renderNoSelection(frame, area); + return; + } + + if (loading.get() && allEntries.isEmpty()) { + frame.renderWidget( + Paragraph.builder() + .text(dev.tamboui.text.Text.from( + Line.from(Span.styled(" Loading heap histogram...", Style.EMPTY.dim())))) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(" Heap Histogram ").build()) + .build(), + area); + return; + } + + List<HeapEntry> visible = sortedEntries(); + + List<Rect> chunks = Layout.vertical() + .constraints(Constraint.length(1), Constraint.fill()) + .split(area); + renderSummary(frame, chunks.get(0), visible); + renderTable(frame, chunks.get(1), visible); + } + + private void renderSummary(Frame frame, Rect area, List<HeapEntry> visible) { + List<Span> spans = new ArrayList<>(); + spans.add(Span.styled(" Classes: ", Style.EMPTY.fg(Color.YELLOW).bold())); + spans.add(Span.styled(String.valueOf(allEntries.size()), Style.EMPTY.fg(Color.WHITE))); + spans.add(Span.raw(" ")); + spans.add(Span.styled("Showing: ", Style.EMPTY.fg(Color.YELLOW).bold())); + spans.add(Span.styled(String.valueOf(visible.size()), Style.EMPTY.fg(Color.WHITE))); + spans.add(Span.raw(" ")); + spans.add(Span.styled("Total Instances: ", Style.EMPTY.fg(Color.YELLOW).bold())); + spans.add(Span.styled(formatNumber(totalInstances), Style.EMPTY.fg(Color.WHITE))); + spans.add(Span.raw(" ")); + spans.add(Span.styled("Total Bytes: ", Style.EMPTY.fg(Color.YELLOW).bold())); + spans.add(Span.styled(formatBytes(totalBytes), Style.EMPTY.fg(Color.WHITE))); + frame.renderWidget(Paragraph.from(Line.from(spans)), area); + } + + private void renderTable(Frame frame, Rect area, List<HeapEntry> visible) { + List<Row> rows = new ArrayList<>(); + for (HeapEntry e : visible) { + rows.add(Row.from( + rightCell(String.valueOf(e.num), 6), + Cell.from(Span.styled(e.className != null ? e.className : "", Style.EMPTY.fg(Color.CYAN))), + rightCell(formatNumber(e.instances), 14), + rightCell(formatBytes(e.bytes), 14))); + } + + if (rows.isEmpty()) { + rows.add(Row.from( + Cell.from(Span.styled("No data", Style.EMPTY.dim())), + Cell.from(""), Cell.from(""), Cell.from(""))); + } + + String title = String.format(" Heap Histogram [%d] sort:%s filter:%s ", + visible.size(), sort, FILTER_LABELS[filter]); + + Table table = Table.builder() + .rows(rows) + .header(Row.from( + rightCell("#", 6, Style.EMPTY.bold()), + Cell.from(Span.styled(sortLabel("CLASS NAME", "className"), sortStyle("className"))), + rightCell(sortLabel("INSTANCES", "instances"), 14, sortStyle("instances")), + rightCell(sortLabel("BYTES", "bytes"), 14, sortStyle("bytes")))) + .widths( + Constraint.length(6), + Constraint.fill(), + Constraint.length(14), + Constraint.length(14)) + .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue()) + .highlightSpacing(Table.HighlightSpacing.ALWAYS) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).title(title).build()) + .build(); + + frame.renderStatefulWidget(table, area, tableState); + } + + @Override + public void renderFooter(List<Span> spans) { + hint(spans, "Esc", "back"); + hint(spans, "s", "sort"); + hint(spans, "f", "filter [" + FILTER_LABELS[filter] + "]"); + hintLast(spans, "F5", "refresh"); + } + + @Override + public SelectionContext getSelectionContext() { + List<HeapEntry> visible = sortedEntries(); + if (visible.isEmpty()) { + return null; + } + List<String> items = visible.stream().map(e -> e.className != null ? e.className : "").toList(); + Integer sel = tableState.selected(); + return new SelectionContext("table", items, sel != null ? sel : -1, items.size(), "Heap Histogram"); + } + + @Override + public JsonObject getTableDataAsJson() { + List<HeapEntry> entries = sortedEntries(); + if (entries.isEmpty()) { + return null; + } + JsonObject result = new JsonObject(); + result.put("tab", "Heap Histogram"); + JsonArray rows = new JsonArray(); + for (HeapEntry e : entries) { + JsonObject row = new JsonObject(); + row.put("num", e.num); + row.put("className", e.className); + row.put("instances", e.instances); + row.put("bytes", e.bytes); + rows.add(row); + } + result.put("rows", rows); + result.put("totalRows", allEntries.size()); + result.put("totalInstances", totalInstances); + result.put("totalBytes", totalBytes); + Integer sel = tableState.selected(); + result.put("selectedIndex", sel != null ? sel : -1); + return result; + } + + @Override + public String getHelpText() { + return """ + # Heap Histogram + + The Heap Histogram tab shows class-level memory usage in the JVM heap, + similar to `jcmd <pid> GC.class_histogram`. Each row represents a class + with the number of live instances and total bytes consumed. + + This is useful for diagnosing memory leaks, finding unexpected object + retention, and understanding which classes dominate heap usage. + + ## Table Columns + + - **#** — Rank by bytes (from the raw JVM histogram) + - **CLASS NAME** — Fully qualified class name. Array types use JVM notation (e.g., `[B` = byte array, `[Ljava.lang.Object;` = Object array) + - **INSTANCES** — Number of live instances of this class on the heap + - **BYTES** — Total bytes consumed by all instances of this class + + ## Example Screen + + ``` + # CLASS NAME INSTANCES BYTES + 1 [B 45,230 12.5 MB + 2 [C 38,100 8.2 MB + 3 java.lang.String 38,050 1.8 MB + 4 java.util.HashMap$Node 12,400 595.0 KB + 5 java.lang.Object[] 8,200 450.2 KB + ``` + + ## Filter Modes + + - **all** (default) — Show all classes + - **camel** — Show only classes from `org.apache.camel` packages + + ## What To Look For + + - **Large byte counts at the top**: Normal for byte arrays (`[B`) and char arrays (`[C`) — these back Strings and buffers + - **Unexpected classes with high counts**: May indicate a memory leak — objects being created but not released + - **Growing instance counts on refresh**: Press F5 repeatedly to spot classes whose instance counts keep growing, which suggests a leak + - **Camel-specific classes**: Use the `camel` filter to focus on Camel's own objects. High counts of Exchange, Message, or Endpoint objects may indicate route issues + + ## Keys + + | Key | Action | + |-----|--------| + | Up/Down | Select class | + | s | Cycle sort column (bytes, instances, className) | + | S | Reverse sort order | + | f | Toggle filter (all / camel) | + | F5 | Refresh heap histogram | + | PgUp/PgDn | Scroll by page | + | Esc | Back | + """; + } + + private List<HeapEntry> sortedEntries() { + List<HeapEntry> result = new ArrayList<>(); + for (HeapEntry e : allEntries) { + if (filter == 1 && isJavaClass(e)) { + continue; + } + if (filter == 2 && !isCamelClass(e)) { + continue; + } + result.add(e); + } + result.sort((a, b) -> { + int cmp = switch (sort) { + case "instances" -> Long.compare(b.instances, a.instances); + case "className" -> compareStr(a.className, b.className); + default -> Long.compare(b.bytes, a.bytes); + }; + return sortReversed ? -cmp : cmp; + }); + return result; + } + + private static boolean isJavaClass(HeapEntry e) { + if (e.className == null) { + return false; + } + return e.className.startsWith("java.") + || e.className.startsWith("javax.") + || e.className.startsWith("jdk.") + || e.className.startsWith("sun.") + || e.className.startsWith("com.sun.") + || e.className.startsWith("["); + } + + private static boolean isCamelClass(HeapEntry e) { + return e.className != null && e.className.contains("org.apache.camel"); + } + + private static int compareStr(String a, String b) { + if (a == null && b == null) { + return 0; + } + if (a == null) { + return -1; + } + if (b == null) { + return 1; + } + return a.compareToIgnoreCase(b); + } + + private String sortLabel(String label, String column) { + return MonitorContext.sortLabel(label, column, sort, sortReversed); + } + + private Style sortStyle(String column) { + return MonitorContext.sortStyle(column, sort); + } + + private void loadHeapHistogram() { + if (ctx.selectedPid == null || ctx.runner == null) { + return; + } + if (!loading.compareAndSet(false, true)) { + return; + } + String pid = ctx.selectedPid; + ctx.runner.scheduler().execute(() -> { + try { + loadHeapHistogramInBackground(pid); + } finally { + loading.set(false); + } + }); + } + + private void loadHeapHistogramInBackground(String pid) { + Path outputFile = ctx.getOutputFile(pid); + PathUtils.deleteFile(outputFile); + + JsonObject root = new JsonObject(); + root.put("action", "heap-histogram"); + + Path actionFile = ctx.getActionFile(pid); + PathUtils.writeTextSafely(root.toJson(), actionFile); + + JsonObject jo = pollJsonResponse(outputFile, 5000); + PathUtils.deleteFile(outputFile); + + if (jo == null) { + return; + } + + long ti = jo.getLongOrDefault("totalInstances", 0); + long tb = jo.getLongOrDefault("totalBytes", 0); + + JsonArray arr = (JsonArray) jo.get("classes"); + if (arr == null) { + return; + } + + List<HeapEntry> result = new ArrayList<>(); + for (int i = 0; i < arr.size(); i++) { + JsonObject ej = (JsonObject) arr.get(i); + HeapEntry entry = new HeapEntry(); + entry.num = ej.getIntegerOrDefault("num", 0); + entry.className = ej.getString("className"); + entry.instances = ej.getLongOrDefault("instances", 0); + entry.bytes = ej.getLongOrDefault("bytes", 0); + result.add(entry); + } + + if (ctx.runner != null) { + ctx.runner.runOnRenderThread(() -> { + allEntries = result; + totalInstances = ti; + totalBytes = tb; + lastPid = pid; + }); + } + } + + static String formatBytes(long bytes) { + if (bytes < 1024) { + return bytes + " B"; + } else if (bytes < 1024 * 1024) { + return String.format("%.1f KB", bytes / 1024.0); + } else if (bytes < 1024L * 1024 * 1024) { + return String.format("%.1f MB", bytes / (1024.0 * 1024)); + } else { + return String.format("%.1f GB", bytes / (1024.0 * 1024 * 1024)); + } + } + + static String formatNumber(long num) { + return String.format("%,d", num); + } + + static class HeapEntry { + int num; + String className; + long instances; + long bytes; + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java index 674c4a3f0f3e..66f6e776b248 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java @@ -90,8 +90,8 @@ class McpFacade { static final String[] MORE_TAB_NAMES = { "Beans", "Browse", "Circuit Breaker", "Classpath", "Configuration", - "Consumers", "DataSource", "Inflight", "Memory", "Metrics", "SQL Query", "SQL Trace", "Spans", "Process", - "Startup", "Threads" + "Consumers", "DataSource", "Heap Histogram", "Inflight", "Memory", "Metrics", "SQL Query", "SQL Trace", + "Spans", "Process", "Startup", "Threads" }; static final Map<String, String> TAB_DESCRIPTIONS = Map.ofEntries( @@ -111,6 +111,8 @@ class McpFacade { Map.entry("Configuration", "Application configuration properties"), Map.entry("Consumers", "Consumer statistics (polling and event-driven consumers)"), Map.entry("DataSource", "JDBC DataSource pool statistics (active, idle, max connections)"), + Map.entry("Heap Histogram", + "Class-level heap memory analysis showing instance counts and byte usage per class"), Map.entry("Inflight", "Currently in-flight exchanges being processed"), Map.entry("Memory", "JVM memory usage (heap/non-heap), GC stats, and thread counts"), Map.entry("Metrics", "Micrometer metrics (counters, gauges, timers, distribution summaries)"), diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java index ba1c87e859ed..7687999001cc 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java @@ -176,7 +176,7 @@ class PopupManager { return true; } if (ke.isDown()) { - morePopupState.selectNext(16); + morePopupState.selectNext(17); return true; } int shortcutSel = morePopupShortcut(ke); @@ -239,7 +239,7 @@ class PopupManager { void renderMorePopup(Frame frame, Rect area) { int popupW = 22; - int popupH = 18; + int popupH = 19; // Position just below the "0 More▾" tab label int dividerW = CharWidth.of(" | "); int tabBarX = 0; @@ -268,6 +268,7 @@ class PopupManager { ListItem.from(Line.from(Span.raw(" Confi"), Span.styled("g", keyStyle), Span.raw("uration"))), ListItem.from(Line.from(Span.raw(" Co"), Span.styled("n", keyStyle), Span.raw("sumers"))), ListItem.from(Line.from(Span.raw(" "), Span.styled("D", keyStyle), Span.raw("ataSource"))), + ListItem.from(Line.from(Span.raw(" "), Span.styled("H", keyStyle), Span.raw("eap Histogram"))), ListItem.from(Line.from(Span.raw(" "), Span.styled("I", keyStyle), Span.raw("nflight"))), ListItem.from(Line.from(Span.raw(" "), Span.styled("M", keyStyle), Span.raw("emory"))), ListItem.from(Line.from(Span.raw(" M"), Span.styled("e", keyStyle), Span.raw("trics"))), @@ -397,33 +398,36 @@ class PopupManager { if (ke.isChar('d')) { return 6; } - if (ke.isChar('i')) { + if (ke.isChar('h')) { return 7; } - if (ke.isChar('m')) { + if (ke.isChar('i')) { return 8; } - if (ke.isChar('e')) { + if (ke.isChar('m')) { return 9; } - if (ke.isChar('q')) { + if (ke.isChar('e')) { return 10; } - if (ke.isChar('r')) { + if (ke.isChar('q')) { return 11; } - if (ke.isChar('o')) { + if (ke.isChar('r')) { return 12; } - if (ke.isChar('p')) { + if (ke.isChar('o')) { return 13; } - if (ke.isChar('s')) { + if (ke.isChar('p')) { return 14; } - if (ke.isChar('t')) { + if (ke.isChar('s')) { return 15; } + if (ke.isChar('t')) { + return 16; + } return -1; } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java index 4fb2bf453383..e0cf19d7e1f7 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java @@ -86,6 +86,7 @@ class TabRegistry { private ProcessTab processTab; private OverviewTab overviewTab; private DataSourceTab dataSourceTab; + private HeapHistogramTab heapHistogramTab; private SqlQueryTab sqlQueryTab; private SqlTraceTab sqlTraceTab; @@ -105,6 +106,7 @@ class TabRegistry { routesTab = new RoutesTab(ctx); consumersTab = new ConsumersTab(ctx); dataSourceTab = new DataSourceTab(ctx); + heapHistogramTab = new HeapHistogramTab(ctx); sqlQueryTab = new SqlQueryTab(ctx); sqlTraceTab = new SqlTraceTab(ctx); endpointsTab = new EndpointsTab(ctx, dataService.metrics()); @@ -129,7 +131,7 @@ class TabRegistry { resetIntegrationTabState); sqlTraceTab.setEditSqlAction(sql -> { - selectMoreTab(10); // switch to SQL Query tab + selectMoreTab(11); // switch to SQL Query tab sqlQueryTab.setInputValue("sql", sql); }); } @@ -216,15 +218,16 @@ class TabRegistry { case 4 -> configurationTab; case 5 -> consumersTab; case 6 -> dataSourceTab; - case 7 -> inflightTab; - case 8 -> memoryTab; - case 9 -> metricsTab; - case 10 -> sqlQueryTab; - case 11 -> sqlTraceTab; - case 12 -> spansTab; - case 13 -> processTab; - case 14 -> startupTab; - case 15 -> threadsTab; + case 7 -> heapHistogramTab; + case 8 -> inflightTab; + case 9 -> memoryTab; + case 10 -> metricsTab; + case 11 -> sqlQueryTab; + case 12 -> sqlTraceTab; + case 13 -> spansTab; + case 14 -> processTab; + case 15 -> startupTab; + case 16 -> threadsTab; default -> null; }; if (activeMoreTab != null) { @@ -247,6 +250,7 @@ class TabRegistry { configurationTab.onIntegrationChanged(); consumersTab.onIntegrationChanged(); dataSourceTab.onIntegrationChanged(); + heapHistogramTab.onIntegrationChanged(); sqlQueryTab.onIntegrationChanged(); sqlTraceTab.onIntegrationChanged(); circuitBreakerTab.onIntegrationChanged();
