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; + } +}
