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();


Reply via email to