This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch obs3
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 90df1b86eec015be32aa8a1f9715279b296b21ee
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat May 30 16:11:38 2026 +0200

    CAMEL-23648: camel-jbang - TUI add Beans and Threads tabs to More menu
    
    Co-Authored-By: Claude Opus 4.6 <[email protected]>
---
 .../dsl/jbang/core/commands/tui/BeansTab.java      | 466 +++++++++++++++++++
 .../dsl/jbang/core/commands/tui/CamelMonitor.java  |  24 +-
 .../dsl/jbang/core/commands/tui/ThreadsTab.java    | 506 +++++++++++++++++++++
 3 files changed, 989 insertions(+), 7 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/BeansTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/BeansTab.java
new file mode 100644
index 000000000000..e3a97f00c142
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/BeansTab.java
@@ -0,0 +1,466 @@
+/*
+ * 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.text.Text;
+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.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 BeansTab implements MonitorTab {
+
+    private static final String[] SORT_COLUMNS = { "name", "type" };
+
+    private final MonitorContext ctx;
+    private final TableState tableState = new TableState();
+    private final AtomicBoolean loading = new AtomicBoolean(false);
+
+    private String sort = "name";
+    private int sortIndex;
+    private boolean sortReversed;
+    private boolean showInternal;
+    private List<BeanData> allBeans = Collections.emptyList();
+    private boolean showDetail;
+    private int detailScroll;
+    private String lastPid;
+
+    BeansTab(MonitorContext ctx) {
+        this.ctx = ctx;
+    }
+
+    @Override
+    public void onTabSelected() {
+        String pid = ctx.selectedPid;
+        if (pid != null && !pid.equals(lastPid)) {
+            lastPid = pid;
+            allBeans = Collections.emptyList();
+        }
+        if (allBeans.isEmpty()) {
+            loadBeans();
+        }
+    }
+
+    @Override
+    public void onIntegrationChanged() {
+        allBeans = Collections.emptyList();
+        showDetail = false;
+        detailScroll = 0;
+        lastPid = null;
+    }
+
+    @Override
+    public boolean handleKeyEvent(KeyEvent ke) {
+        if (showDetail) {
+            if (ke.isUp()) {
+                detailScroll = Math.max(0, detailScroll - 1);
+                return true;
+            }
+            if (ke.isDown()) {
+                detailScroll++;
+                return true;
+            }
+            if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) {
+                detailScroll = Math.max(0, detailScroll - 20);
+                return true;
+            }
+            if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) {
+                detailScroll += 20;
+                return true;
+            }
+            return false;
+        }
+
+        if (ke.isConfirm()) {
+            showDetail = !showDetail;
+            detailScroll = 0;
+            return true;
+        }
+        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('i')) {
+            showInternal = !showInternal;
+            return true;
+        }
+        if (ke.isCharIgnoreCase('r')) {
+            loadBeans();
+            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<BeanData> visible = sortedBeans();
+            for (int i = 0; i < 20; i++) {
+                tableState.selectNext(visible.size());
+            }
+            return true;
+        }
+        return false;
+    }
+
+    @Override
+    public boolean handleEscape() {
+        if (showDetail) {
+            showDetail = false;
+            return true;
+        }
+        return false;
+    }
+
+    @Override
+    public void navigateUp() {
+        if (!showDetail) {
+            tableState.selectPrevious();
+        }
+    }
+
+    @Override
+    public void navigateDown() {
+        if (!showDetail) {
+            List<BeanData> visible = sortedBeans();
+            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() && allBeans.isEmpty()) {
+            frame.renderWidget(
+                    Paragraph.builder()
+                            .text(Text.from(Line.from(Span.styled(" Loading 
beans...", Style.EMPTY.dim()))))
+                            
.block(Block.builder().borderType(BorderType.ROUNDED).title(" Beans ").build())
+                            .build(),
+                    area);
+            return;
+        }
+
+        List<BeanData> visible = sortedBeans();
+
+        if (showDetail) {
+            List<Rect> chunks = Layout.vertical()
+                    .constraints(Constraint.percentage(40), Constraint.fill())
+                    .split(area);
+            renderTable(frame, chunks.get(0), visible);
+            renderDetail(frame, chunks.get(1), visible);
+        } else {
+            renderTable(frame, area, visible);
+        }
+    }
+
+    private void renderTable(Frame frame, Rect area, List<BeanData> visible) {
+        List<Row> rows = new ArrayList<>();
+        for (BeanData b : visible) {
+            String type = b.type != null ? b.type : "";
+            int dot = type.lastIndexOf('.');
+            String shortType = dot >= 0 ? type.substring(dot + 1) : type;
+
+            rows.add(Row.from(
+                    Cell.from(Span.styled(b.name != null ? b.name : "", 
Style.EMPTY.fg(Color.CYAN))),
+                    Cell.from(Span.styled(shortType, Style.EMPTY)),
+                    Cell.from(Span.styled(type, Style.EMPTY.dim()))));
+        }
+
+        if (rows.isEmpty()) {
+            rows.add(Row.from(
+                    Cell.from(Span.styled("No beans", Style.EMPTY.dim())),
+                    Cell.from(""), Cell.from("")));
+        }
+
+        String title = String.format(" Beans [%d] sort:%s ", visible.size(), 
sort);
+        if (showInternal) {
+            title = String.format(" Beans [%d] sort:%s internal:on ", 
visible.size(), sort);
+        }
+
+        Table table = Table.builder()
+                .rows(rows)
+                .header(Row.from(
+                        Cell.from(Span.styled(sortLabel("NAME", "name"), 
sortStyle("name"))),
+                        Cell.from(Span.styled(sortLabel("TYPE", "type"), 
sortStyle("type"))),
+                        Cell.from(Span.styled("PACKAGE", Style.EMPTY.bold()))))
+                .widths(
+                        Constraint.length(30),
+                        Constraint.length(30),
+                        Constraint.fill())
+                .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue())
+                .highlightSpacing(Table.HighlightSpacing.ALWAYS)
+                
.block(Block.builder().borderType(BorderType.ROUNDED).title(title).build())
+                .build();
+
+        frame.renderStatefulWidget(table, area, tableState);
+    }
+
+    private void renderDetail(Frame frame, Rect area, List<BeanData> visible) {
+        Integer sel = tableState.selected();
+        if (sel == null || sel < 0 || sel >= visible.size()) {
+            frame.renderWidget(
+                    Paragraph.builder()
+                            .text(Text.from(Line.from(Span.styled(" Select a 
bean", Style.EMPTY.dim()))))
+                            
.block(Block.builder().borderType(BorderType.ROUNDED).title(" Properties 
").build())
+                            .build(),
+                    area);
+            return;
+        }
+
+        BeanData bean = visible.get(sel);
+        String title = " " + bean.name + " (" + (bean.type != null ? bean.type 
: "") + ") ";
+
+        if (bean.properties == null || bean.properties.isEmpty()) {
+            frame.renderWidget(
+                    Paragraph.builder()
+                            .text(Text.from(Line.from(Span.styled(" No 
properties", Style.EMPTY.dim()))))
+                            
.block(Block.builder().borderType(BorderType.ROUNDED).title(title).build())
+                            .build(),
+                    area);
+            return;
+        }
+
+        int visibleLines = area.height() - 2;
+        if (visibleLines < 1) {
+            visibleLines = 1;
+        }
+        int maxScroll = Math.max(0, bean.properties.size() - visibleLines);
+        detailScroll = Math.min(detailScroll, maxScroll);
+
+        int end = Math.min(detailScroll + visibleLines, 
bean.properties.size());
+        List<Line> lines = new ArrayList<>();
+        for (int i = detailScroll; i < end; i++) {
+            BeanData.Property prop = bean.properties.get(i);
+            String propType = prop.type != null ? prop.type : "";
+            int dot = propType.lastIndexOf('.');
+            String shortPropType = dot >= 0 ? propType.substring(dot + 1) : 
propType;
+            String value = prop.value != null ? prop.value : "null";
+
+            lines.add(Line.from(
+                    Span.styled("  " + String.format("%-25s", prop.name), 
Style.EMPTY.fg(Color.CYAN)),
+                    Span.styled(String.format("%-15s", shortPropType), 
Style.EMPTY.dim()),
+                    Span.styled(" = ", Style.EMPTY.dim()),
+                    Span.styled(value, "null".equals(value) ? 
Style.EMPTY.dim() : Style.EMPTY.fg(Color.WHITE))));
+        }
+
+        frame.renderWidget(
+                Paragraph.builder()
+                        .text(Text.from(lines))
+                        
.block(Block.builder().borderType(BorderType.ROUNDED).title(title).build())
+                        .build(),
+                area);
+    }
+
+    @Override
+    public void renderFooter(List<Span> spans) {
+        hint(spans, "Esc", "back");
+        hint(spans, "s", "sort");
+        hint(spans, "i", "internal" + (showInternal ? " [on]" : ""));
+        hint(spans, "r", "refresh");
+        if (showDetail) {
+            hintLast(spans, "↑↓", "scroll");
+        } else {
+            hintLast(spans, "Enter", "detail");
+        }
+    }
+
+    @Override
+    public SelectionContext getSelectionContext() {
+        List<BeanData> visible = sortedBeans();
+        if (visible.isEmpty()) {
+            return null;
+        }
+        List<String> items = visible.stream().map(b -> b.name != null ? b.name 
: "").toList();
+        Integer sel = tableState.selected();
+        return new SelectionContext("table", items, sel != null ? sel : -1, 
items.size(), "Beans");
+    }
+
+    private List<BeanData> sortedBeans() {
+        List<BeanData> result = new ArrayList<>();
+        for (BeanData b : allBeans) {
+            if (!showInternal && b.internal) {
+                continue;
+            }
+            result.add(b);
+        }
+        result.sort((a, b) -> {
+            int cmp = switch (sort) {
+                case "type" -> compareStr(a.type, b.type);
+                default -> compareStr(a.name, b.name);
+            };
+            return sortReversed ? -cmp : cmp;
+        });
+        return result;
+    }
+
+    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 loadBeans() {
+        if (ctx.selectedPid == null || ctx.runner == null) {
+            return;
+        }
+        if (!loading.compareAndSet(false, true)) {
+            return;
+        }
+        String pid = ctx.selectedPid;
+        ctx.runner.scheduler().execute(() -> {
+            try {
+                loadBeansInBackground(pid);
+            } finally {
+                loading.set(false);
+            }
+        });
+    }
+
+    private void loadBeansInBackground(String pid) {
+        Path outputFile = ctx.getOutputFile(pid);
+        PathUtils.deleteFile(outputFile);
+
+        JsonObject root = new JsonObject();
+        root.put("action", "bean");
+        root.put("properties", true);
+        root.put("nulls", true);
+        root.put("internal", true);
+
+        Path actionFile = ctx.getActionFile(pid);
+        PathUtils.writeTextSafely(root.toJson(), actionFile);
+
+        JsonObject jo = pollJsonResponse(outputFile, 5000);
+        PathUtils.deleteFile(outputFile);
+
+        if (jo == null) {
+            return;
+        }
+
+        JsonObject beans = (JsonObject) jo.get("beans");
+        if (beans == null) {
+            return;
+        }
+
+        List<BeanData> result = new ArrayList<>();
+        for (String name : beans.keySet()) {
+            JsonObject bj = (JsonObject) beans.get(name);
+            BeanData bd = new BeanData();
+            bd.name = bj.getString("name");
+            bd.type = bj.getString("type");
+            bd.internal = isInternalBean(bd.name, bd.type);
+
+            JsonArray props = bj.getCollection("properties");
+            if (props != null && !props.isEmpty()) {
+                bd.properties = new ArrayList<>();
+                for (int i = 0; i < props.size(); i++) {
+                    JsonObject pj = (JsonObject) props.get(i);
+                    BeanData.Property prop = new BeanData.Property();
+                    prop.name = pj.getString("name");
+                    prop.type = pj.getString("type");
+                    Object val = pj.get("value");
+                    prop.value = val != null ? val.toString() : null;
+                    bd.properties.add(prop);
+                }
+            }
+            result.add(bd);
+        }
+
+        if (ctx.runner != null) {
+            ctx.runner.runOnRenderThread(() -> {
+                allBeans = result;
+                lastPid = pid;
+            });
+        }
+    }
+
+    private static boolean isInternalBean(String name, String type) {
+        if (name == null) {
+            return false;
+        }
+        if (name.startsWith("camel-") || name.startsWith("org.apache.camel")) {
+            return true;
+        }
+        if (type != null && type.startsWith("org.apache.camel")) {
+            return true;
+        }
+        return false;
+    }
+
+    static class BeanData {
+        String name;
+        String type;
+        boolean internal;
+        List<Property> properties;
+
+        static class Property {
+            String name;
+            String type;
+            String value;
+        }
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index f07a205ad802..118093da8cc5 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -279,6 +279,8 @@ public class CamelMonitor extends CamelCommand {
     private MetricsTab metricsTab;
     private StartupTab startupTab;
     private ConfigurationTab configurationTab;
+    private BeansTab beansTab;
+    private ThreadsTab threadsTab;
 
     // "More" dropdown state
     private boolean showMorePopup;
@@ -338,6 +340,8 @@ public class CamelMonitor extends CamelCommand {
         metricsTab = new MetricsTab(ctx);
         startupTab = new StartupTab(ctx);
         configurationTab = new ConfigurationTab(ctx);
+        beansTab = new BeansTab(ctx);
+        threadsTab = new ThreadsTab(ctx);
 
         // Initial data load (synchronous before TUI starts)
         refreshDataSync();
@@ -431,7 +435,7 @@ public class CamelMonitor extends CamelCommand {
                     return true;
                 }
                 if (ke.isDown()) {
-                    morePopupState.selectNext(4);
+                    morePopupState.selectNext(6);
                     return true;
                 }
                 if (ke.isConfirm()) {
@@ -440,10 +444,12 @@ public class CamelMonitor extends CamelCommand {
                     if (sel != null) {
                         lastMoreSelection = sel;
                         activeMoreTab = switch (sel) {
-                            case 0 -> circuitBreakerTab;
-                            case 1 -> configurationTab;
-                            case 2 -> consumersTab;
-                            case 3 -> startupTab;
+                            case 0 -> beansTab;
+                            case 1 -> circuitBreakerTab;
+                            case 2 -> configurationTab;
+                            case 3 -> consumersTab;
+                            case 4 -> startupTab;
+                            case 5 -> threadsTab;
                             default -> null;
                         };
                         if (activeMoreTab != null) {
@@ -893,6 +899,8 @@ public class CamelMonitor extends CamelCommand {
         httpTab.onIntegrationChanged();
         logTab.onIntegrationChanged();
         historyTab.onIntegrationChanged();
+        beansTab.onIntegrationChanged();
+        threadsTab.onIntegrationChanged();
     }
 
     private void navigateUp() {
@@ -1180,7 +1188,7 @@ public class CamelMonitor extends CamelCommand {
 
     private void renderMorePopup(Frame frame, Rect area) {
         int popupW = 22;
-        int popupH = 6;
+        int popupH = 8;
         // Position just below the "0 More▾" tab label
         int dividerW = CharWidth.of(" | ");
         int tabBarX = 0;
@@ -1201,10 +1209,12 @@ public class CamelMonitor extends CamelCommand {
         frame.renderWidget(Clear.INSTANCE, popup);
 
         ListItem[] items = {
+                ListItem.from("  Beans"),
                 ListItem.from("  Circuit Breaker"),
                 ListItem.from("  Configuration"),
                 ListItem.from("  Consumers"),
                 ListItem.from("  Startup"),
+                ListItem.from("  Threads"),
         };
         ListWidget list = ListWidget.builder()
                 .items(items)
@@ -1213,7 +1223,7 @@ public class CamelMonitor extends CamelCommand {
                 .scrollMode(ScrollMode.NONE)
                 .block(Block.builder()
                         .borderType(BorderType.ROUNDED)
-                        .title(" More Tabs ")
+                        .title(Title.from(Line.from(Span.styled(" More Tabs ", 
Style.EMPTY.fg(Color.YELLOW).bold()))))
                         .build())
                 .build();
         frame.renderStatefulWidget(list, popup, morePopupState);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ThreadsTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ThreadsTab.java
new file mode 100644
index 000000000000..5b9ba072d5be
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ThreadsTab.java
@@ -0,0 +1,506 @@
+/*
+ * 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.text.Text;
+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.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 ThreadsTab implements MonitorTab {
+
+    private static final String[] SORT_COLUMNS = { "id", "name", "state" };
+    private static final String[] FILTER_LABELS = { "camel", "all" };
+
+    private final MonitorContext ctx;
+    private final TableState tableState = new TableState();
+    private final AtomicBoolean loading = new AtomicBoolean(false);
+
+    private String sort = "id";
+    private int sortIndex;
+    private boolean sortReversed;
+    private int filter; // 0=camel, 1=all
+    private List<ThreadData> allThreads = Collections.emptyList();
+    private int threadCount;
+    private int peakThreadCount;
+    private boolean showTrace;
+    private int traceScroll;
+    private String lastPid;
+
+    ThreadsTab(MonitorContext ctx) {
+        this.ctx = ctx;
+    }
+
+    @Override
+    public void onTabSelected() {
+        String pid = ctx.selectedPid;
+        if (pid != null && !pid.equals(lastPid)) {
+            lastPid = pid;
+            allThreads = Collections.emptyList();
+        }
+        if (allThreads.isEmpty()) {
+            loadThreads();
+        }
+    }
+
+    @Override
+    public void onIntegrationChanged() {
+        allThreads = Collections.emptyList();
+        showTrace = false;
+        traceScroll = 0;
+        lastPid = null;
+    }
+
+    @Override
+    public boolean handleKeyEvent(KeyEvent ke) {
+        if (showTrace) {
+            if (ke.isUp()) {
+                traceScroll = Math.max(0, traceScroll - 1);
+                return true;
+            }
+            if (ke.isDown()) {
+                traceScroll++;
+                return true;
+            }
+            if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) {
+                traceScroll = Math.max(0, traceScroll - 20);
+                return true;
+            }
+            if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) {
+                traceScroll += 20;
+                return true;
+            }
+            return false;
+        }
+
+        if (ke.isConfirm()) {
+            showTrace = !showTrace;
+            traceScroll = 0;
+            return true;
+        }
+        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.isCharIgnoreCase('r')) {
+            loadThreads();
+            return true;
+        }
+        if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) {
+            List<ThreadData> visible = sortedThreads();
+            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<ThreadData> visible = sortedThreads();
+            for (int i = 0; i < 20; i++) {
+                tableState.selectNext(visible.size());
+            }
+            return true;
+        }
+        return false;
+    }
+
+    @Override
+    public boolean handleEscape() {
+        if (showTrace) {
+            showTrace = false;
+            return true;
+        }
+        return false;
+    }
+
+    @Override
+    public void navigateUp() {
+        if (!showTrace) {
+            tableState.selectPrevious();
+        }
+    }
+
+    @Override
+    public void navigateDown() {
+        if (!showTrace) {
+            List<ThreadData> visible = sortedThreads();
+            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() && allThreads.isEmpty()) {
+            frame.renderWidget(
+                    Paragraph.builder()
+                            .text(Text.from(Line.from(Span.styled(" Loading 
threads...", Style.EMPTY.dim()))))
+                            
.block(Block.builder().borderType(BorderType.ROUNDED).title(" Threads 
").build())
+                            .build(),
+                    area);
+            return;
+        }
+
+        List<ThreadData> visible = sortedThreads();
+
+        if (showTrace) {
+            List<Rect> chunks = Layout.vertical()
+                    .constraints(Constraint.length(1), 
Constraint.percentage(40), Constraint.fill())
+                    .split(area);
+            renderSummary(frame, chunks.get(0), visible);
+            renderTable(frame, chunks.get(1), visible);
+            renderTrace(frame, chunks.get(2), visible);
+        } else {
+            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<ThreadData> 
visible) {
+        List<Span> spans = new ArrayList<>();
+        spans.add(Span.styled(" Threads: ", 
Style.EMPTY.fg(Color.YELLOW).bold()));
+        spans.add(Span.styled(String.valueOf(threadCount), 
Style.EMPTY.fg(Color.WHITE)));
+        spans.add(Span.raw("    "));
+        spans.add(Span.styled("Peak: ", Style.EMPTY.fg(Color.YELLOW).bold()));
+        spans.add(Span.styled(String.valueOf(peakThreadCount), 
Style.EMPTY.fg(Color.WHITE)));
+        spans.add(Span.raw("    "));
+        spans.add(Span.styled("Showing: ", 
Style.EMPTY.fg(Color.YELLOW).bold()));
+        spans.add(Span.styled(visible.size() + "/" + allThreads.size(), 
Style.EMPTY.fg(Color.WHITE)));
+        frame.renderWidget(Paragraph.from(Line.from(spans)), area);
+    }
+
+    private void renderTable(Frame frame, Rect area, List<ThreadData> visible) 
{
+        List<Row> rows = new ArrayList<>();
+        for (ThreadData t : visible) {
+            String state = t.state != null ? t.state : "";
+            String blocked = t.blockedTime > 0
+                    ? t.blockedCount + "(" + t.blockedTime + "ms)"
+                    : String.valueOf(t.blockedCount);
+            String waited = t.waitedTime > 0
+                    ? t.waitedCount + "(" + t.waitedTime + "ms)"
+                    : String.valueOf(t.waitedCount);
+
+            rows.add(Row.from(
+                    rightCell(String.valueOf(t.id), 8),
+                    Cell.from(Span.styled(t.name != null ? t.name : "", 
Style.EMPTY.fg(Color.CYAN))),
+                    Cell.from(Span.styled(state, stateStyle(state))),
+                    rightCell(blocked, 14),
+                    rightCell(waited, 14)));
+        }
+
+        if (rows.isEmpty()) {
+            rows.add(Row.from(
+                    Cell.from(Span.styled("No threads", Style.EMPTY.dim())),
+                    Cell.from(""), Cell.from(""), Cell.from(""), 
Cell.from("")));
+        }
+
+        String title = String.format(" Threads [%d] sort:%s filter:%s ", 
visible.size(), sort, FILTER_LABELS[filter]);
+
+        Table table = Table.builder()
+                .rows(rows)
+                .header(Row.from(
+                        rightCell(sortLabel("ID", "id"), 8, sortStyle("id")),
+                        Cell.from(Span.styled(sortLabel("NAME", "name"), 
sortStyle("name"))),
+                        Cell.from(Span.styled(sortLabel("STATE", "state"), 
sortStyle("state"))),
+                        rightCell("BLOCKED", 14, Style.EMPTY.bold()),
+                        rightCell("WAITED", 14, Style.EMPTY.bold())))
+                .widths(
+                        Constraint.length(8),
+                        Constraint.fill(),
+                        Constraint.length(16),
+                        Constraint.length(14),
+                        Constraint.length(14))
+                .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue())
+                .highlightSpacing(Table.HighlightSpacing.ALWAYS)
+                
.block(Block.builder().borderType(BorderType.ROUNDED).title(title).build())
+                .build();
+
+        frame.renderStatefulWidget(table, area, tableState);
+    }
+
+    private void renderTrace(Frame frame, Rect area, List<ThreadData> visible) 
{
+        Integer sel = tableState.selected();
+        if (sel == null || sel < 0 || sel >= visible.size()) {
+            frame.renderWidget(
+                    Paragraph.builder()
+                            .text(Text.from(Line.from(Span.styled(" Select a 
thread", Style.EMPTY.dim()))))
+                            
.block(Block.builder().borderType(BorderType.ROUNDED).title(" Stack Trace 
").build())
+                            .build(),
+                    area);
+            return;
+        }
+
+        ThreadData thread = visible.get(sel);
+        String title = " Thread " + thread.id + " " + (thread.name != null ? 
thread.name : "") + " ["
+                       + (thread.state != null ? thread.state : "") + "] ";
+
+        if (thread.stackTrace == null || thread.stackTrace.isEmpty()) {
+            frame.renderWidget(
+                    Paragraph.builder()
+                            .text(Text.from(Line.from(Span.styled(" No stack 
trace available", Style.EMPTY.dim()))))
+                            
.block(Block.builder().borderType(BorderType.ROUNDED).title(title).build())
+                            .build(),
+                    area);
+            return;
+        }
+
+        int visibleLines = area.height() - 2;
+        if (visibleLines < 1) {
+            visibleLines = 1;
+        }
+        int maxScroll = Math.max(0, thread.stackTrace.size() - visibleLines);
+        traceScroll = Math.min(traceScroll, maxScroll);
+
+        int end = Math.min(traceScroll + visibleLines, 
thread.stackTrace.size());
+        List<Line> lines = new ArrayList<>();
+        for (int i = traceScroll; i < end; i++) {
+            String frame2 = thread.stackTrace.get(i);
+            Style style = Style.EMPTY;
+            if (frame2 != null && frame2.contains("org.apache.camel")) {
+                style = Style.EMPTY.fg(Color.YELLOW);
+            }
+            lines.add(Line.from(Span.styled("  " + (frame2 != null ? frame2 : 
""), style)));
+        }
+
+        frame.renderWidget(
+                Paragraph.builder()
+                        .text(Text.from(lines))
+                        
.block(Block.builder().borderType(BorderType.ROUNDED).title(title).build())
+                        .build(),
+                area);
+    }
+
+    @Override
+    public void renderFooter(List<Span> spans) {
+        hint(spans, "Esc", "back");
+        hint(spans, "s", "sort");
+        hint(spans, "f", "filter [" + FILTER_LABELS[filter] + "]");
+        hint(spans, "r", "refresh");
+        if (showTrace) {
+            hintLast(spans, "↑↓", "scroll");
+        } else {
+            hintLast(spans, "Enter", "trace");
+        }
+    }
+
+    @Override
+    public SelectionContext getSelectionContext() {
+        List<ThreadData> visible = sortedThreads();
+        if (visible.isEmpty()) {
+            return null;
+        }
+        List<String> items = visible.stream().map(t -> t.name != null ? t.name 
: "").toList();
+        Integer sel = tableState.selected();
+        return new SelectionContext("table", items, sel != null ? sel : -1, 
items.size(), "Threads");
+    }
+
+    private List<ThreadData> sortedThreads() {
+        List<ThreadData> result = new ArrayList<>();
+        for (ThreadData t : allThreads) {
+            if (filter == 0 && !isCamelThread(t)) {
+                continue;
+            }
+            result.add(t);
+        }
+        result.sort((a, b) -> {
+            int cmp = switch (sort) {
+                case "name" -> compareStr(a.name, b.name);
+                case "state" -> compareStr(a.state, b.state);
+                default -> Long.compare(a.id, b.id);
+            };
+            return sortReversed ? -cmp : cmp;
+        });
+        return result;
+    }
+
+    private static boolean isCamelThread(ThreadData t) {
+        if (t.name == null) {
+            return false;
+        }
+        String lower = t.name.toLowerCase();
+        return lower.contains("camel") || lower.contains("vertx") || 
lower.contains("netty");
+    }
+
+    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 static Style stateStyle(String state) {
+        if (state == null) {
+            return Style.EMPTY;
+        }
+        return switch (state) {
+            case "RUNNABLE" -> Style.EMPTY.fg(Color.GREEN);
+            case "BLOCKED" -> Style.EMPTY.fg(Color.LIGHT_RED);
+            case "WAITING" -> Style.EMPTY.fg(Color.YELLOW);
+            case "TIMED_WAITING" -> Style.EMPTY.fg(Color.CYAN);
+            default -> Style.EMPTY;
+        };
+    }
+
+    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 loadThreads() {
+        if (ctx.selectedPid == null || ctx.runner == null) {
+            return;
+        }
+        if (!loading.compareAndSet(false, true)) {
+            return;
+        }
+        String pid = ctx.selectedPid;
+        ctx.runner.scheduler().execute(() -> {
+            try {
+                loadThreadsInBackground(pid);
+            } finally {
+                loading.set(false);
+            }
+        });
+    }
+
+    private void loadThreadsInBackground(String pid) {
+        Path outputFile = ctx.getOutputFile(pid);
+        PathUtils.deleteFile(outputFile);
+
+        JsonObject root = new JsonObject();
+        root.put("action", "thread-dump");
+
+        Path actionFile = ctx.getActionFile(pid);
+        PathUtils.writeTextSafely(root.toJson(), actionFile);
+
+        JsonObject jo = pollJsonResponse(outputFile, 5000);
+        PathUtils.deleteFile(outputFile);
+
+        if (jo == null) {
+            return;
+        }
+
+        int tc = jo.getIntegerOrDefault("threadCount", 0);
+        int peak = jo.getIntegerOrDefault("peakThreadCount", 0);
+
+        JsonArray arr = (JsonArray) jo.get("threads");
+        if (arr == null) {
+            return;
+        }
+
+        List<ThreadData> result = new ArrayList<>();
+        for (int i = 0; i < arr.size(); i++) {
+            JsonObject tj = (JsonObject) arr.get(i);
+            ThreadData td = new ThreadData();
+            Long idVal = tj.getLong("id");
+            td.id = idVal != null ? idVal : 0;
+            td.name = tj.getString("name");
+            td.state = tj.getString("state");
+            Long bc = tj.getLong("blockedCount");
+            td.blockedCount = bc != null ? bc : 0;
+            Long bt = tj.getLong("blockedTime");
+            td.blockedTime = bt != null ? bt : 0;
+            Long wc = tj.getLong("waitedCount");
+            td.waitedCount = wc != null ? wc : 0;
+            Long wt = tj.getLong("waitedTime");
+            td.waitedTime = wt != null ? wt : 0;
+            td.lockName = tj.getString("lockName");
+
+            JsonArray st = tj.getCollection("stackTrace");
+            if (st != null && !st.isEmpty()) {
+                td.stackTrace = new ArrayList<>();
+                for (int j = 0; j < st.size(); j++) {
+                    Object frame = st.get(j);
+                    td.stackTrace.add(frame != null ? frame.toString() : "");
+                }
+            }
+            result.add(td);
+        }
+
+        if (ctx.runner != null) {
+            ctx.runner.runOnRenderThread(() -> {
+                allThreads = result;
+                threadCount = tc;
+                peakThreadCount = peak;
+                lastPid = pid;
+            });
+        }
+    }
+
+    static class ThreadData {
+        long id;
+        String name;
+        String state;
+        long blockedCount;
+        long blockedTime;
+        long waitedCount;
+        long waitedTime;
+        String lockName;
+        List<String> stackTrace;
+    }
+}


Reply via email to