From bb432b625b57a663b7db48a14ae1f775f10746ad Mon Sep 17 00:00:00 2001 From: Claus Ibsen Date: Fri, 29 May 2026 19:15:18 +0200 Subject: [PATCH 1/4] CAMEL-23615: camel-jbang - TUI add Metrics tab with dashboard, table, and raw view Co-Authored-By: Claude --- .../jbang/core/commands/tui/CamelMonitor.java | 222 ++++- .../core/commands/tui/IntegrationInfo.java | 1 + .../jbang/core/commands/tui/MetricsTab.java | 890 ++++++++++++++++++ .../commands/tui/MicrometerMeterInfo.java | 41 + 4 files changed, 1125 insertions(+), 29 deletions(-) create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MetricsTab.java create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MicrometerMeterInfo.java 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 f728deed7dd67..d18adb6253761 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 @@ -73,6 +73,10 @@ import dev.tamboui.widgets.block.Block; import dev.tamboui.widgets.block.BorderType; import dev.tamboui.widgets.block.Title; +import dev.tamboui.widgets.list.ListItem; +import dev.tamboui.widgets.list.ListState; +import dev.tamboui.widgets.list.ListWidget; +import dev.tamboui.widgets.list.ScrollMode; import dev.tamboui.widgets.paragraph.Paragraph; import dev.tamboui.widgets.table.Cell; import dev.tamboui.widgets.table.Row; @@ -119,8 +123,8 @@ public class CamelMonitor extends CamelCommand { private static final int TAB_HEALTH = 5; private static final int TAB_HISTORY = 6; private static final int TAB_ERRORS = 7; - private static final int TAB_CIRCUIT_BREAKER = 8; - private static final int TAB_CONSUMERS = 9; + private static final int TAB_METRICS = 8; + private static final int TAB_MORE = 9; // Overview sort columns private static final String[] OVERVIEW_SORT_COLUMNS = { "pid", "name", "version", "status", "total", "fail" }; @@ -271,6 +275,13 @@ public class CamelMonitor extends CamelCommand { private HistoryTab historyTab; private CircuitBreakerTab circuitBreakerTab; private ErrorsTab errorsTab; + private MetricsTab metricsTab; + + // "More" dropdown state + private boolean showMorePopup; + private final ListState morePopupState = new ListState(); + private MonitorTab activeMoreTab; + private Line[] currentTabLabels; private ClassLoader classLoader; @@ -320,6 +331,7 @@ public Integer doCall() throws Exception { historyTab = new HistoryTab(ctx, traces, traceFilePositions); circuitBreakerTab = new CircuitBreakerTab(ctx, cbSuccessHistory, cbFailHistory); errorsTab = new ErrorsTab(ctx); + metricsTab = new MetricsTab(ctx); // Initial data load (synchronous before TUI starts) refreshDataSync(); @@ -402,6 +414,37 @@ private boolean handleEvent(Event event, TuiRunner runner) { if (actionsPopup.isVisible()) { return actionsPopup.handleKeyEvent(ke); } + // "More" tab popup + if (showMorePopup) { + if (ke.isCancel()) { + showMorePopup = false; + return true; + } + if (ke.isUp()) { + morePopupState.selectPrevious(); + return true; + } + if (ke.isDown()) { + morePopupState.selectNext(2); + return true; + } + if (ke.isConfirm()) { + showMorePopup = false; + Integer sel = morePopupState.selected(); + if (sel != null) { + activeMoreTab = switch (sel) { + case 0 -> circuitBreakerTab; + case 1 -> consumersTab; + default -> null; + }; + if (activeMoreTab != null) { + activeMoreTab.onTabSelected(); + } + } + return true; + } + return true; + } // Kill confirm dialog: Enter to confirm, Esc/any other key to cancel if (showKillConfirm) { if (ke.isConfirm()) { @@ -463,10 +506,10 @@ private boolean handleEvent(Event event, TuiRunner runner) { return handleTabKey(TAB_ERRORS); } if (ke.isChar('9')) { - return handleTabKey(TAB_CIRCUIT_BREAKER); + return handleTabKey(TAB_METRICS); } if (ke.isChar('0')) { - return handleTabKey(TAB_CONSUMERS); + return handleTabKey(TAB_MORE); } } @@ -733,9 +776,6 @@ private boolean handleTabKey(int tab) { } historyTab.onTabSelected(); } - if (tab == TAB_CIRCUIT_BREAKER) { - circuitBreakerTab.onTabSelected(); - } if (tab == TAB_ERRORS && ctx.selectedPid != null) { try { long pid = Long.parseLong(ctx.selectedPid); @@ -745,6 +785,12 @@ private boolean handleTabKey(int tab) { } errorsTab.onTabSelected(); } + if (tab == TAB_MORE) { + showMorePopup = true; + tabsState.select(tab); + return true; + } + showMorePopup = false; tabsState.select(tab); return true; } @@ -968,7 +1014,6 @@ private void renderTabs(Frame frame, Rect area) { IntegrationInfo sel = findSelectedIntegration(); boolean hasSelection = ctx.selectedPid != null && sel != null; int routeCount = hasSelection ? sel.routes.size() : 0; - int consumerCount = hasSelection ? sel.consumers.size() : 0; int endpointCount = hasSelection ? sel.endpoints.size() : 0; int cbCount = hasSelection ? sel.circuitBreakers.size() : 0; long cbOpenCount = hasSelection @@ -984,6 +1029,8 @@ private void renderTabs(Frame frame, Rect area) { boolean hasTraces = hasSelection && !traces.get().isEmpty(); int httpCount = hasSelection ? sel.httpEndpoints.size() : 0; + int metricsCount = hasSelection ? sel.meters.size() : 0; + // Row 0: label-only titles — fixed width so the tab bar never shifts when badges appear Line[] labels = { Line.from(" 1 Overview "), @@ -994,9 +1041,10 @@ private void renderTabs(Frame frame, Rect area) { Line.from(" 6 Health "), Line.from(" 7 Inspect "), Line.from(" 8 Errors "), - Line.from(" 9 Circuit Breaker "), - Line.from(" 0 Consumer "), + Line.from(" 9 Metrics "), + Line.from(" 0 More▾ "), }; + currentTabLabels = labels; Tabs tabs = Tabs.builder() .titles(labels) @@ -1030,9 +1078,6 @@ private void renderTabs(Frame frame, Rect area) { if (routeCount > 0) { badgeTexts[TAB_ROUTES] = "(" + routeCount + ")"; } - if (consumerCount > 0) { - badgeTexts[TAB_CONSUMERS] = "(" + consumerCount + ")"; - } if (endpointCount > 0) { badgeTexts[TAB_ENDPOINTS] = "(" + endpointCount + ")"; } @@ -1051,11 +1096,12 @@ private void renderTabs(Frame frame, Rect area) { } else if (historyCount > 0) { badgeTexts[TAB_HISTORY] = "(" + historyCount + ")"; } + if (metricsCount > 0) { + badgeTexts[TAB_METRICS] = "(" + metricsCount + ")"; + } if (cbOpenCount > 0) { - badgeTexts[TAB_CIRCUIT_BREAKER] = "(" + cbOpenCount + " OPEN)"; - badgeStyles[TAB_CIRCUIT_BREAKER] = red; - } else if (cbCount > 0) { - badgeTexts[TAB_CIRCUIT_BREAKER] = "(" + cbCount + ")"; + badgeTexts[TAB_MORE] = "(" + cbOpenCount + " OPEN)"; + badgeStyles[TAB_MORE] = red; } int errorCount = hasSelection ? sel.errorCount : 0; if (errorCount > 0) { @@ -1090,19 +1136,62 @@ private void renderContent(Frame frame, Rect area) { } else { renderOverview(frame, area); } + // Render "More" popup overlay when visible + if (showMorePopup) { + renderMorePopup(frame, area); + } + } + + private void renderMorePopup(Frame frame, Rect area) { + int popupW = 22; + int popupH = 4; + // Position just below the "0 More▾" tab label + int dividerW = CharWidth.of(" | "); + int tabBarX = 0; + Line[] tabLabels = currentTabLabels; + if (tabLabels != null) { + for (int i = 0; i < tabLabels.length - 1; i++) { + tabBarX += tabLabels[i].width(); + tabBarX += dividerW; + } + } + int x = area.left() + tabBarX; + int y = area.top(); + if (x + popupW > area.right()) { + x = Math.max(area.left(), area.right() - popupW); + } + Rect popup = new Rect(x, y, Math.min(popupW, area.width() - (x - area.left())), Math.min(popupH, area.height())); + + frame.renderWidget(Clear.INSTANCE, popup); + + ListItem[] items = { + ListItem.from(" Circuit Breaker"), + ListItem.from(" Consumers"), + }; + ListWidget list = ListWidget.builder() + .items(items) + .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue()) + .highlightSymbol("") + .scrollMode(ScrollMode.NONE) + .block(Block.builder() + .borderType(BorderType.ROUNDED) + .title(" More Tabs ") + .build()) + .build(); + frame.renderStatefulWidget(list, popup, morePopupState); } private MonitorTab activeTab() { return switch (tabsState.selected()) { case TAB_LOG -> logTab; case TAB_ROUTES -> routesTab; - case TAB_CONSUMERS -> consumersTab; case TAB_ENDPOINTS -> endpointsTab; - case TAB_CIRCUIT_BREAKER -> circuitBreakerTab; case TAB_HEALTH -> healthTab; case TAB_HISTORY -> historyTab; case TAB_HTTP -> httpTab; case TAB_ERRORS -> errorsTab; + case TAB_METRICS -> metricsTab; + case TAB_MORE -> activeMoreTab; default -> null; }; } @@ -1874,17 +1963,23 @@ private void renderFooter(Frame frame, Rect area) { return; } - MonitorTab tab = activeTab(); - - if (tab != null) { - tab.renderFooter(spans); - // Insert F2 after the first hint (Esc) — each hint is 2 spans (key + label) - int insertPos = Math.min(2, spans.size()); - List f2Spans = new ArrayList<>(); - hint(f2Spans, "F2", "actions"); - spans.addAll(insertPos, f2Spans); + if (showMorePopup) { + hint(spans, "Up/Down", "select"); + hint(spans, "Enter", "open"); + hint(spans, "Esc", "close"); } else { - renderOverviewFooter(spans); + MonitorTab tab = activeTab(); + + if (tab != null) { + tab.renderFooter(spans); + // Insert F2 after the first hint (Esc) — each hint is 2 spans (key + label) + int insertPos = Math.min(2, spans.size()); + List f2Spans = new ArrayList<>(); + hint(f2Spans, "F2", "actions"); + spans.addAll(insertPos, f2Spans); + } else { + renderOverviewFooter(spans); + } } List rightSpans = new ArrayList<>(); @@ -3178,6 +3273,16 @@ private IntegrationInfo parseIntegration(ProcessHandle ph, JsonObject root) { info.errorCount = errorsObj.getIntegerOrDefault("size", 0); } + // Parse micrometer metrics (optional, only present when --observe is used) + JsonObject micrometerObj = (JsonObject) root.get("micrometer"); + if (micrometerObj != null) { + parseMicrometerMeters(micrometerObj, "counters", "counter", info); + parseMicrometerMeters(micrometerObj, "gauges", "gauge", info); + parseMicrometerMeters(micrometerObj, "timers", "timer", info); + parseMicrometerMeters(micrometerObj, "longTaskTimers", "longTaskTimer", info); + parseMicrometerMeters(micrometerObj, "distribution", "distribution", info); + } + // Parse REST DSL services JsonObject restsObj = (JsonObject) root.get("rests"); if (restsObj != null) { @@ -3289,6 +3394,65 @@ private static void parseCbSection(JsonObject root, String key, IntegrationInfo } } + @SuppressWarnings("unchecked") + private static void parseMicrometerMeters( + JsonObject micrometerObj, String section, String type, IntegrationInfo info) { + JsonArray arr = (JsonArray) micrometerObj.get(section); + if (arr == null) { + return; + } + for (Object o : arr) { + JsonObject jo = (JsonObject) o; + MicrometerMeterInfo m = new MicrometerMeterInfo(); + m.type = type; + m.name = jo.getString("name"); + m.description = jo.getString("description"); + // parse tags + JsonArray tagsArr = (JsonArray) jo.get("tags"); + if (tagsArr != null) { + for (Object t : tagsArr) { + JsonObject tj = (JsonObject) t; + m.tags.add(new String[] { tj.getString("key"), tj.getString("value") }); + } + } + // parse type-specific values + switch (type) { + case "counter": + m.count = TuiHelper.objToLong(jo.get("count")); + break; + case "gauge": + Object v = jo.get("value"); + m.value = v instanceof Number n ? n.doubleValue() : null; + break; + case "timer": + m.count = TuiHelper.objToLong(jo.get("count")); + m.mean = TuiHelper.objToLong(jo.get("mean")); + m.max = TuiHelper.objToLong(jo.get("max")); + m.total = TuiHelper.objToLong(jo.get("total")); + break; + case "longTaskTimer": + Object at = jo.get("activeTasks"); + m.activeTasks = at instanceof Number n ? n.intValue() : null; + m.mean = TuiHelper.objToLong(jo.get("mean")); + m.max = TuiHelper.objToLong(jo.get("max")); + m.total = TuiHelper.objToLong(jo.get("duration")); + break; + case "distribution": + m.count = TuiHelper.objToLong(jo.get("count")); + Object dm = jo.get("mean"); + m.meanDouble = dm instanceof Number n ? n.doubleValue() : null; + Object dx = jo.get("max"); + m.maxDouble = dx instanceof Number n ? n.doubleValue() : null; + Object dt = jo.get("totalAmount"); + m.totalDouble = dt instanceof Number n ? n.doubleValue() : null; + break; + default: + break; + } + info.meters.add(m); + } + } + @SuppressWarnings("unchecked") private static void parseKvArray(JsonArray arr, Map values, Map types) { if (arr == null) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java index 018fabc1a6f11..8b2d78db5d054 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java @@ -65,6 +65,7 @@ class IntegrationInfo { final List circuitBreakers = new ArrayList<>(); int errorCount; final List errors = new ArrayList<>(); + final List meters = new ArrayList<>(); final List httpEndpoints = new ArrayList<>(); String httpServer; String readmeFiles; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MetricsTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MetricsTab.java new file mode 100644 index 0000000000000..c4b26037fc2c8 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MetricsTab.java @@ -0,0 +1,890 @@ +/* + * 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.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; + +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.scrollbar.Scrollbar; +import dev.tamboui.widgets.scrollbar.ScrollbarState; +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 static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.*; + +class MetricsTab implements MonitorTab { + + private static final String[] SORT_COLUMNS = { "name", "type", "value" }; + private static final String[] FILTER_TYPES = { "all", "counter", "gauge", "timer", "longTaskTimer", "distribution" }; + + private static final Style LABEL = Style.EMPTY.dim(); + private static final Style VALUE = Style.EMPTY.fg(Color.WHITE).bold(); + private static final Style HEADER = Style.EMPTY.fg(Color.YELLOW).bold(); + private static final Style GOOD = Style.EMPTY.fg(Color.GREEN); + private static final Style BAD = Style.EMPTY.fg(Color.LIGHT_RED); + + private final MonitorContext ctx; + private final TableState tableState = new TableState(); + private final ScrollbarState scrollbarState = new ScrollbarState(); + private final ScrollbarState rawScrollbarState = new ScrollbarState(); + private boolean tableMode; + private int lastRowCount; + private String sort = "name"; + private int sortIndex; + private boolean sortReversed; + private String filterType = "all"; + private int filterIndex; + + // raw metrics view + private boolean showRaw; + private List rawLines = Collections.emptyList(); + private int rawScroll; + private String rawTitle; + private String rawContentType; + private final AtomicBoolean rawLoading = new AtomicBoolean(false); + + MetricsTab(MonitorContext ctx) { + this.ctx = ctx; + } + + @Override + public boolean handleKeyEvent(KeyEvent ke) { + // raw view scrolling + if (showRaw) { + if (ke.isUp()) { + rawScroll = Math.max(0, rawScroll - 1); + return true; + } + if (ke.isDown()) { + rawScroll++; + return true; + } + if (ke.isPageUp()) { + rawScroll = Math.max(0, rawScroll - 20); + return true; + } + if (ke.isPageDown()) { + rawScroll += 20; + return true; + } + if (ke.isHome()) { + rawScroll = 0; + return true; + } + if (ke.isEnd()) { + rawScroll = Integer.MAX_VALUE; + return true; + } + if (ke.isKey(KeyCode.F5)) { + loadRawMetrics(); + return true; + } + return false; + } + + if (ke.isChar('r')) { + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info != null && findMetricsUrl(info) != null) { + showRaw = true; + rawScroll = 0; + loadRawMetrics(); + return true; + } + } + if (ke.isChar('d')) { + tableMode = !tableMode; + return true; + } + if (tableMode) { + if (ke.isPageUp()) { + for (int i = 0; i < 10; i++) { + tableState.selectPrevious(); + } + return true; + } + if (ke.isPageDown()) { + for (int i = 0; i < 10; i++) { + tableState.selectNext(lastRowCount); + } + return true; + } + if (ke.isHome()) { + tableState.selectFirst(); + return true; + } + if (ke.isEnd()) { + tableState.selectLast(lastRowCount); + 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.isChar('f')) { + filterIndex = (filterIndex + 1) % FILTER_TYPES.length; + filterType = FILTER_TYPES[filterIndex]; + return true; + } + } + return false; + } + + @Override + public boolean handleEscape() { + if (showRaw) { + showRaw = false; + return true; + } + if (tableMode) { + tableMode = false; + return true; + } + return false; + } + + @Override + public void navigateUp() { + if (tableMode) { + tableState.selectPrevious(); + } + } + + @Override + public void navigateDown() { + if (tableMode) { + tableState.selectNext(lastRowCount); + } + } + + @Override + public void render(Frame frame, Rect area) { + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info == null) { + renderNoSelection(frame, area); + return; + } + + if (info.meters.isEmpty()) { + Paragraph p = Paragraph.from(Line.from( + Span.styled("No metrics available. Run with --observe to enable micrometer.", LABEL))); + frame.renderWidget(p, area); + return; + } + + if (showRaw) { + renderRaw(frame, area); + } else if (tableMode) { + renderTable(frame, area, info); + } else { + renderDashboard(frame, area, info); + } + } + + // ---- Dashboard mode ---- + + private void renderDashboard(Frame frame, Rect area, IntegrationInfo info) { + String metricsUrl = findMetricsUrl(info); + Rect panelArea = area; + if (metricsUrl != null) { + List vParts = Layout.vertical() + .constraints(Constraint.length(1), Constraint.fill()) + .split(area); + frame.renderWidget(Paragraph.from(Line.from( + Span.styled(" Endpoint: ", LABEL), + Span.styled(metricsUrl, Style.EMPTY.fg(Color.CYAN)))), vParts.get(0)); + panelArea = vParts.get(1); + } + + List panels = Layout.horizontal() + .constraints(Constraint.percentage(50), Constraint.percentage(50)) + .split(panelArea); + + renderCamelPanel(frame, panels.get(0), info.meters); + renderJvmPanel(frame, panels.get(1), info.meters); + } + + private void renderCamelPanel(Frame frame, Rect area, List meters) { + List lines = new ArrayList<>(); + + // Exchanges section + lines.add(Line.from(Span.styled(" Exchanges", HEADER))); + lines.add(Line.empty()); + + long total = counterValue(meters, "camel.exchanges.total"); + long failed = counterValue(meters, "camel.exchanges.failed"); + long succeeded = counterValue(meters, "camel.exchanges.succeeded"); + long inflight = gaugeValueLong(meters, "camel.exchanges.inflight"); + long extReloaded = counterValue(meters, "camel.exchanges.external.reloaded"); + + lines.add(Line.from( + Span.styled(" Total: ", LABEL), + Span.styled(formatNumber(total), VALUE), + Span.styled(" Inflight: ", LABEL), + Span.styled(String.valueOf(inflight), inflight > 0 ? GOOD : VALUE))); + lines.add(Line.from( + Span.styled(" Succeeded: ", LABEL), + Span.styled(formatNumber(succeeded), GOOD), + Span.styled(" Failed: ", LABEL), + Span.styled(formatNumber(failed), failed > 0 ? BAD : VALUE))); + if (extReloaded > 0) { + lines.add(Line.from( + Span.styled(" Ext Reloaded: ", LABEL), + Span.styled(formatNumber(extReloaded), VALUE))); + } + lines.add(Line.empty()); + + // Route timers + List routeTimers = findMeters(meters, "camel.route.policy"); + if (!routeTimers.isEmpty()) { + lines.add(Line.from(Span.styled(" Route Timers", HEADER), + Span.styled(" mean / max", LABEL))); + lines.add(Line.empty()); + for (MicrometerMeterInfo rt : routeTimers) { + String routeId = tagValue(rt, "routeId"); + if (routeId == null) { + routeId = tagValue(rt, "routeid"); + } + if (routeId == null) { + routeId = "?"; + } + String timing = String.format("%dms / %dms", + rt.mean != null ? rt.mean : 0, + rt.max != null ? rt.max : 0); + int pad = Math.max(1, 30 - routeId.length()); + lines.add(Line.from( + Span.styled(" " + routeId, Style.EMPTY.fg(Color.CYAN)), + Span.styled(" ".repeat(pad), Style.EMPTY), + Span.styled(timing, VALUE))); + } + } + + // Exchange event notifier timers + List eventTimers = findMeters(meters, "camel.exchange.event.notifier"); + if (!eventTimers.isEmpty()) { + lines.add(Line.empty()); + lines.add(Line.from(Span.styled(" Event Notifiers", HEADER), + Span.styled(" mean / max", LABEL))); + lines.add(Line.empty()); + for (MicrometerMeterInfo et : eventTimers) { + String name = et.name != null ? et.name : "?"; + String shortName = name.replace("camel.exchange.event.notifier.", ""); + String timing = String.format("%dms / %dms", + et.mean != null ? et.mean : 0, + et.max != null ? et.max : 0); + int pad = Math.max(1, 30 - shortName.length()); + lines.add(Line.from( + Span.styled(" " + shortName, Style.EMPTY.fg(Color.CYAN)), + Span.styled(" ".repeat(pad), Style.EMPTY), + Span.styled(timing, VALUE))); + } + } + + Paragraph paragraph = Paragraph.builder() + .text(Text.from(lines)) + .block(Block.builder().borderType(BorderType.ROUNDED).title(" Camel ").build()) + .build(); + frame.renderWidget(paragraph, area); + } + + private void renderJvmPanel(Frame frame, Rect area, List meters) { + List lines = new ArrayList<>(); + + // Memory section + lines.add(Line.from(Span.styled(" Memory", HEADER))); + lines.add(Line.empty()); + + double heapUsed = gaugeValue(meters, "jvm.memory.used", "area", "heap"); + double heapMax = gaugeValue(meters, "jvm.memory.max", "area", "heap"); + double heapCommitted = gaugeValue(meters, "jvm.memory.committed", "area", "heap"); + double nonHeapUsed = gaugeValue(meters, "jvm.memory.used", "area", "nonheap"); + + String heapStr = formatBytes(heapUsed); + String heapMaxStr = heapMax > 0 ? formatBytes(heapMax) : "-"; + String memBar = heapMax > 0 ? memoryBar(heapUsed, heapMax, 10) : ""; + + lines.add(Line.from( + Span.styled(" Heap: ", LABEL), + Span.styled(heapStr, VALUE), + Span.styled(" / ", LABEL), + Span.styled(heapMaxStr, VALUE), + Span.styled(" ", Style.EMPTY), + Span.styled(memBar, heapMax > 0 && heapUsed / heapMax > 0.85 ? BAD : GOOD))); + + if (heapCommitted > 0) { + lines.add(Line.from( + Span.styled(" Committed: ", LABEL), + Span.styled(formatBytes(heapCommitted), VALUE))); + } + lines.add(Line.from( + Span.styled(" Non-heap: ", LABEL), + Span.styled(formatBytes(nonHeapUsed), VALUE))); + lines.add(Line.empty()); + + // Runtime section + lines.add(Line.from(Span.styled(" Runtime", HEADER))); + lines.add(Line.empty()); + + double cpuProcess = gaugeValue(meters, "process.cpu.usage"); + double cpuSystem = gaugeValue(meters, "system.cpu.usage"); + double threads = gaugeValue(meters, "jvm.threads.live"); + if (threads == 0) { + threads = gaugeValue(meters, "jvm.threads.current"); + } + double peakThreads = gaugeValue(meters, "jvm.threads.peak"); + double daemonThreads = gaugeValue(meters, "jvm.threads.daemon"); + + if (cpuProcess >= 0) { + lines.add(Line.from( + Span.styled(" CPU (proc): ", LABEL), + Span.styled(String.format("%.1f%%", cpuProcess * 100), VALUE))); + } + if (cpuSystem >= 0) { + lines.add(Line.from( + Span.styled(" CPU (sys): ", LABEL), + Span.styled(String.format("%.1f%%", cpuSystem * 100), VALUE))); + } + if (threads > 0) { + lines.add(Line.from( + Span.styled(" Threads: ", LABEL), + Span.styled(String.valueOf((long) threads), VALUE), + peakThreads > 0 + ? Span.styled(" (peak: " + (long) peakThreads + ")", LABEL) + : Span.raw(""))); + } + if (daemonThreads > 0) { + lines.add(Line.from( + Span.styled(" Daemon: ", LABEL), + Span.styled(String.valueOf((long) daemonThreads), VALUE))); + } + + // GC + long gcCount = counterValue(meters, "jvm.gc.pause"); + List gcTimers = findMeters(meters, "jvm.gc.pause"); + if (!gcTimers.isEmpty()) { + lines.add(Line.empty()); + lines.add(Line.from(Span.styled(" Garbage Collection", HEADER))); + lines.add(Line.empty()); + for (MicrometerMeterInfo gc : gcTimers) { + String cause = tagValue(gc, "cause"); + String action = tagValue(gc, "action"); + String label = cause != null ? cause : (action != null ? action : "GC"); + lines.add(Line.from( + Span.styled(" " + label + ": ", LABEL), + Span.styled("count=", LABEL), + Span.styled(String.valueOf(gc.count != null ? gc.count : 0), VALUE), + Span.styled(", total=", LABEL), + Span.styled((gc.total != null ? gc.total : 0) + "ms", VALUE))); + } + } + + Paragraph paragraph = Paragraph.builder() + .text(Text.from(lines)) + .block(Block.builder().borderType(BorderType.ROUNDED).title(" JVM ").build()) + .build(); + frame.renderWidget(paragraph, area); + } + + // ---- Table mode ---- + + private void renderTable(Frame frame, Rect area, IntegrationInfo info) { + List filtered = info.meters; + if (!"all".equals(filterType)) { + filtered = filtered.stream() + .filter(m -> filterType.equals(m.type)) + .collect(Collectors.toList()); + } + + List sorted = new ArrayList<>(filtered); + sorted.sort(this::sortMeter); + + List rows = new ArrayList<>(); + for (MicrometerMeterInfo m : sorted) { + String typeLabel = typeLabel(m.type); + Style typeStyle = typeStyle(m.type); + String value = formatValue(m); + String tags = formatTags(m); + + rows.add(Row.from( + Cell.from(Span.styled(typeLabel, typeStyle)), + Cell.from(Span.styled(m.name != null ? m.name : "", Style.EMPTY.fg(Color.CYAN))), + Cell.from(value), + Cell.from(Span.styled(tags, Style.EMPTY.dim())))); + } + + lastRowCount = rows.size(); + if (!rows.isEmpty() && tableState.selected() == null) { + tableState.select(0); + } + + if (rows.isEmpty()) { + String msg = "No " + filterType + " metrics"; + rows.add(Row.from( + Cell.from(Span.styled(msg, Style.EMPTY.dim())), + Cell.from(""), Cell.from(""), Cell.from(""))); + } + + String title = " Metrics"; + if (!"all".equals(filterType)) { + title += " filter:" + filterType; + } + title += " sort:" + sort + " (" + sorted.size() + ") "; + + Table table = Table.builder() + .rows(rows) + .header(Row.from( + Cell.from(Span.styled(sortLabel("TYPE", "type"), sortStyle("type"))), + Cell.from(Span.styled(sortLabel("NAME", "name"), sortStyle("name"))), + Cell.from(Span.styled(sortLabel("VALUE", "value"), sortStyle("value"))), + Cell.from(Span.styled("TAGS", Style.EMPTY.bold())))) + .widths( + Constraint.length(12), + Constraint.length(40), + Constraint.percentage(30), + Constraint.fill()) + .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue()) + .block(Block.builder().borderType(BorderType.ROUNDED) + .title(title).build()) + .build(); + + frame.renderStatefulWidget(table, area, tableState); + + int visibleRows = Math.max(1, area.height() - 4); + if (lastRowCount > visibleRows) { + Integer sel = tableState.selected(); + scrollbarState + .contentLength(lastRowCount) + .viewportContentLength(visibleRows) + .position(sel != null ? sel : 0); + frame.renderStatefulWidget(Scrollbar.builder().build(), area, scrollbarState); + } + } + + // ---- Raw metrics view ---- + + private void renderRaw(Frame frame, Rect area) { + String ct = rawContentType != null ? " " + rawContentType : ""; + String title = " Raw Metrics (" + rawLines.size() + " lines)" + ct + " [" + (rawTitle != null ? rawTitle : "") + "] "; + + Block block = Block.builder() + .borderType(BorderType.ROUNDED) + .title(title) + .build(); + Rect inner = block.inner(area); + frame.renderWidget(block, area); + + if (rawLines.isEmpty()) { + return; + } + + int visibleLines = inner.height(); + int maxScroll = Math.max(0, rawLines.size() - visibleLines); + rawScroll = Math.min(rawScroll, maxScroll); + + int end = Math.min(rawScroll + visibleLines, rawLines.size()); + List visible = new ArrayList<>(); + for (int i = rawScroll; i < end; i++) { + visible.add(colorPrometheusLine(rawLines.get(i))); + } + + List hChunks = Layout.horizontal() + .constraints(Constraint.fill(), Constraint.length(1)) + .split(inner); + + frame.renderWidget(Paragraph.builder().text(Text.from(visible)).build(), hChunks.get(0)); + + if (rawLines.size() > visibleLines) { + rawScrollbarState + .contentLength(rawLines.size()) + .viewportContentLength(visibleLines) + .position(rawScroll); + frame.renderStatefulWidget(Scrollbar.builder().build(), hChunks.get(1), rawScrollbarState); + } + } + + private void loadRawMetrics() { + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info == null) { + return; + } + String url = findMetricsUrl(info); + if (url == null) { + return; + } + if (!rawLoading.compareAndSet(false, true)) { + return; + } + rawTitle = url; + + ctx.runner.scheduler().execute(() -> { + try { + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(10)) + .GET() + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + String ct = response.headers().firstValue("Content-Type").orElse(null); + List lines = List.of(response.body().split("\n", -1)); + applyRawResult(url, lines, ct); + } catch (Exception e) { + applyRawResult(url, List.of("(Error fetching metrics: " + e.getMessage() + ")"), null); + } finally { + rawLoading.set(false); + } + }); + } + + private void applyRawResult(String url, List lines, String contentType) { + if (ctx.runner == null) { + return; + } + ctx.runner.runOnRenderThread(() -> { + if (!showRaw) { + return; + } + rawTitle = url; + rawLines = lines; + rawContentType = contentType; + }); + } + + @Override + public void renderFooter(List spans) { + if (showRaw) { + hint(spans, "↑↓", "scroll"); + hint(spans, "PgUp/Dn", "page"); + hint(spans, "F5", "refresh"); + hintLast(spans, "Esc", "close"); + return; + } + hint(spans, "Esc", "back"); + hint(spans, "d", tableMode ? "dashboard" : "table"); + if (tableMode) { + hint(spans, "s", "sort"); + hint(spans, "f", "filter:" + filterType); + } + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info != null && findMetricsUrl(info) != null) { + hint(spans, "r", "raw"); + } + hint(spans, "1-9", "tabs"); + } + + // ---- Prometheus syntax coloring ---- + + private static final Style PROM_COMMENT = Style.EMPTY.dim(); + private static final Style PROM_NAME = Style.EMPTY.fg(Color.CYAN); + private static final Style PROM_VALUE = Style.EMPTY.fg(Color.WHITE).bold(); + private static final Style PROM_TAGS = Style.EMPTY.fg(Color.YELLOW); + + private static Line colorPrometheusLine(String line) { + if (line.startsWith("#")) { + return Line.from(Span.styled(line, PROM_COMMENT)); + } + // metric line: name{tags} value or name value + int braceStart = line.indexOf('{'); + int braceEnd = line.indexOf('}'); + if (braceStart > 0 && braceEnd > braceStart) { + String name = line.substring(0, braceStart); + String tags = line.substring(braceStart, braceEnd + 1); + String rest = line.substring(braceEnd + 1).trim(); + return Line.from( + Span.styled(name, PROM_NAME), + Span.styled(tags, PROM_TAGS), + Span.styled(" " + rest, PROM_VALUE)); + } + // name value (no tags) + int space = line.indexOf(' '); + if (space > 0) { + String name = line.substring(0, space); + String value = line.substring(space); + return Line.from( + Span.styled(name, PROM_NAME), + Span.styled(value, PROM_VALUE)); + } + return Line.from(Span.raw(line)); + } + + // ---- Endpoint helpers ---- + + private static String findMetricsUrl(IntegrationInfo info) { + for (HttpEndpointInfo ep : info.httpEndpoints) { + if (ep.management && ep.url != null && ep.url.contains("/metrics")) { + return ep.url; + } + } + return null; + } + + // ---- Meter lookup helpers ---- + + private static MicrometerMeterInfo findMeter(List meters, String name) { + for (MicrometerMeterInfo m : meters) { + if (name.equals(m.name)) { + return m; + } + } + return null; + } + + private static MicrometerMeterInfo findMeter( + List meters, String name, String tagKey, + String tagVal) { + for (MicrometerMeterInfo m : meters) { + if (name.equals(m.name) && hasTag(m, tagKey, tagVal)) { + return m; + } + } + return null; + } + + private static List findMeters(List meters, String namePrefix) { + return meters.stream() + .filter(m -> m.name != null && m.name.startsWith(namePrefix)) + .collect(Collectors.toList()); + } + + private static boolean hasTag(MicrometerMeterInfo m, String key, String value) { + for (String[] tag : m.tags) { + if (key.equals(tag[0]) && value.equals(tag[1])) { + return true; + } + } + return false; + } + + private static String tagValue(MicrometerMeterInfo m, String key) { + for (String[] tag : m.tags) { + if (key.equals(tag[0])) { + return tag[1]; + } + } + return null; + } + + private static long counterValue(List meters, String name) { + MicrometerMeterInfo m = findMeter(meters, name); + return m != null && m.count != null ? m.count : 0; + } + + private static double gaugeValue(List meters, String name) { + MicrometerMeterInfo m = findMeter(meters, name); + return m != null && m.value != null ? m.value : -1; + } + + private static double gaugeValue(List meters, String name, String tagKey, String tagVal) { + MicrometerMeterInfo m = findMeter(meters, name, tagKey, tagVal); + return m != null && m.value != null ? m.value : 0; + } + + private static long gaugeValueLong(List meters, String name) { + MicrometerMeterInfo m = findMeter(meters, name); + return m != null && m.value != null ? m.value.longValue() : 0; + } + + // ---- Formatting helpers ---- + + private static String formatNumber(long n) { + if (n >= 1_000_000) { + return String.format("%.1fM", n / 1_000_000.0); + } + if (n >= 10_000) { + return String.format("%.1fK", n / 1_000.0); + } + return String.valueOf(n); + } + + private static String formatBytes(double bytes) { + if (bytes <= 0) { + return "0"; + } + if (bytes >= 1_073_741_824) { + return String.format("%.1fGB", bytes / 1_073_741_824.0); + } + if (bytes >= 1_048_576) { + return String.format("%.0fMB", bytes / 1_048_576.0); + } + if (bytes >= 1024) { + return String.format("%.0fKB", bytes / 1024.0); + } + return String.format("%.0fB", bytes); + } + + private static String memoryBar(double used, double max, int width) { + if (max <= 0) { + return ""; + } + double ratio = Math.min(1.0, used / max); + int filled = (int) Math.round(ratio * width); + return "[" + "▓".repeat(filled) + "░".repeat(width - filled) + "]"; + } + + // ---- Table mode helpers ---- + + 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 int sortMeter(MicrometerMeterInfo a, MicrometerMeterInfo b) { + int result = switch (sort) { + case "type" -> { + String ta = a.type != null ? a.type : ""; + String tb = b.type != null ? b.type : ""; + yield ta.compareToIgnoreCase(tb); + } + case "value" -> Double.compare(numericValue(b), numericValue(a)); + default -> { // "name" + String na = a.name != null ? a.name : ""; + String nb = b.name != null ? b.name : ""; + yield na.compareToIgnoreCase(nb); + } + }; + return sortReversed ? -result : result; + } + + private static double numericValue(MicrometerMeterInfo m) { + if (m.count != null) { + return m.count; + } + if (m.value != null) { + return m.value; + } + return 0; + } + + private static String typeLabel(String type) { + if (type == null) { + return ""; + } + return switch (type) { + case "counter" -> "counter"; + case "gauge" -> "gauge"; + case "timer" -> "timer"; + case "longTaskTimer" -> "long-task"; + case "distribution" -> "dist"; + default -> type; + }; + } + + private static Style typeStyle(String type) { + if (type == null) { + return Style.EMPTY; + } + return switch (type) { + case "counter" -> Style.EMPTY.fg(Color.LIGHT_BLUE); + case "gauge" -> Style.EMPTY.fg(Color.LIGHT_GREEN); + case "timer" -> Style.EMPTY.fg(Color.LIGHT_YELLOW); + case "longTaskTimer" -> Style.EMPTY.fg(Color.LIGHT_MAGENTA); + case "distribution" -> Style.EMPTY.fg(Color.LIGHT_CYAN); + default -> Style.EMPTY; + }; + } + + private static String formatValue(MicrometerMeterInfo m) { + if (m.type == null) { + return ""; + } + return switch (m.type) { + case "counter" -> m.count != null ? String.valueOf(m.count) : "0"; + case "gauge" -> m.value != null ? String.format("%.1f", m.value) : "0.0"; + case "timer" -> String.format("count=%d, mean=%dms, max=%dms", + m.count != null ? m.count : 0, + m.mean != null ? m.mean : 0, + m.max != null ? m.max : 0); + case "longTaskTimer" -> String.format("tasks=%d, mean=%dms, max=%dms", + m.activeTasks != null ? m.activeTasks : 0, + m.mean != null ? m.mean : 0, + m.max != null ? m.max : 0); + case "distribution" -> String.format("count=%d, mean=%.1f, max=%.1f", + m.count != null ? m.count : 0, + m.meanDouble != null ? m.meanDouble : 0.0, + m.maxDouble != null ? m.maxDouble : 0.0); + default -> ""; + }; + } + + private static String formatTags(MicrometerMeterInfo m) { + if (m.tags.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < m.tags.size(); i++) { + if (i > 0) { + sb.append(", "); + } + String[] tag = m.tags.get(i); + sb.append(tag[0]).append("=").append(tag[1]); + } + return sb.toString(); + } + + @Override + public SelectionContext getSelectionContext() { + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info == null || info.meters.isEmpty()) { + return null; + } + List filtered = info.meters; + if (!"all".equals(filterType)) { + filtered = filtered.stream() + .filter(m -> filterType.equals(m.type)) + .collect(Collectors.toList()); + } + List sorted = new ArrayList<>(filtered); + sorted.sort(this::sortMeter); + List items = sorted.stream().map(m -> m.name != null ? m.name : "").toList(); + Integer sel = tableState.selected(); + return new SelectionContext("table", items, sel != null ? sel : -1, items.size(), "Metrics"); + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MicrometerMeterInfo.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MicrometerMeterInfo.java new file mode 100644 index 0000000000000..2d8fe7f856013 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MicrometerMeterInfo.java @@ -0,0 +1,41 @@ +/* + * 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.util.ArrayList; +import java.util.List; + +class MicrometerMeterInfo { + String type; + String name; + String description; + final List tags = new ArrayList<>(); + // counter + Long count; + // gauge + Double value; + // timer, longTaskTimer + Long mean; + Long max; + Long total; + // longTaskTimer + Integer activeTasks; + // distribution + Double meanDouble; + Double maxDouble; + Double totalDouble; +} From acf295061a395b21066fe8d4f373169bcda1c727 Mon Sep 17 00:00:00 2001 From: Claus Ibsen Date: Fri, 29 May 2026 20:03:39 +0200 Subject: [PATCH 2/4] CAMEL-23615: camel-jbang - TUI add Startup Timeline tab Co-Authored-By: Claude --- .../jbang/core/commands/tui/CamelMonitor.java | 8 +- .../jbang/core/commands/tui/StartupTab.java | 363 ++++++++++++++++++ 2 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StartupTab.java 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 d18adb6253761..d2bb3d1e858dc 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 @@ -276,6 +276,7 @@ public class CamelMonitor extends CamelCommand { private CircuitBreakerTab circuitBreakerTab; private ErrorsTab errorsTab; private MetricsTab metricsTab; + private StartupTab startupTab; // "More" dropdown state private boolean showMorePopup; @@ -332,6 +333,7 @@ public Integer doCall() throws Exception { circuitBreakerTab = new CircuitBreakerTab(ctx, cbSuccessHistory, cbFailHistory); errorsTab = new ErrorsTab(ctx); metricsTab = new MetricsTab(ctx); + startupTab = new StartupTab(ctx); // Initial data load (synchronous before TUI starts) refreshDataSync(); @@ -425,7 +427,7 @@ private boolean handleEvent(Event event, TuiRunner runner) { return true; } if (ke.isDown()) { - morePopupState.selectNext(2); + morePopupState.selectNext(3); return true; } if (ke.isConfirm()) { @@ -435,6 +437,7 @@ private boolean handleEvent(Event event, TuiRunner runner) { activeMoreTab = switch (sel) { case 0 -> circuitBreakerTab; case 1 -> consumersTab; + case 2 -> startupTab; default -> null; }; if (activeMoreTab != null) { @@ -1144,7 +1147,7 @@ private void renderContent(Frame frame, Rect area) { private void renderMorePopup(Frame frame, Rect area) { int popupW = 22; - int popupH = 4; + int popupH = 5; // Position just below the "0 More▾" tab label int dividerW = CharWidth.of(" | "); int tabBarX = 0; @@ -1167,6 +1170,7 @@ private void renderMorePopup(Frame frame, Rect area) { ListItem[] items = { ListItem.from(" Circuit Breaker"), ListItem.from(" Consumers"), + ListItem.from(" Startup"), }; ListWidget list = ListWidget.builder() .items(items) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StartupTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StartupTab.java new file mode 100644 index 0000000000000..6854935115d7c --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StartupTab.java @@ -0,0 +1,363 @@ +/* + * 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.scrollbar.Scrollbar; +import dev.tamboui.widgets.scrollbar.ScrollbarState; +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 StartupTab implements MonitorTab { + + private static final Style LABEL = Style.EMPTY.dim(); + private static final Style VALUE = Style.EMPTY.fg(Color.WHITE).bold(); + private static final Style HEADER = Style.EMPTY.fg(Color.YELLOW).bold(); + + private static final Style[] BAND_STYLES = { + Style.EMPTY.fg(Color.GREEN), + Style.EMPTY.fg(Color.LIGHT_GREEN), + Style.EMPTY.fg(Color.YELLOW), + Style.EMPTY.fg(Color.rgb(0xFF, 0xA5, 0x00)), + Style.EMPTY.fg(Color.RED), + }; + + private final MonitorContext ctx; + private final ScrollbarState scrollbarState = new ScrollbarState(); + private final AtomicBoolean loading = new AtomicBoolean(false); + + private List steps = Collections.emptyList(); + private int scrollOffset; + private long totalDuration; + private long maxDuration; + private long minDurationColor; + private long maxDurationColor; + private String errorMessage; + private boolean dataLoaded; + + StartupTab(MonitorContext ctx) { + this.ctx = ctx; + } + + @Override + public void onTabSelected() { + if (!dataLoaded) { + loadStartupData(); + } + } + + @Override + public boolean handleKeyEvent(KeyEvent ke) { + if (ke.isUp()) { + scrollOffset = Math.max(0, scrollOffset - 1); + return true; + } + if (ke.isDown()) { + scrollOffset++; + return true; + } + if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) { + scrollOffset = Math.max(0, scrollOffset - 20); + return true; + } + if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) { + scrollOffset += 20; + return true; + } + if (ke.isHome()) { + scrollOffset = 0; + return true; + } + if (ke.isEnd()) { + scrollOffset = Integer.MAX_VALUE; + return true; + } + if (ke.isKey(KeyCode.F5)) { + loadStartupData(); + return true; + } + return false; + } + + @Override + public boolean handleEscape() { + return false; + } + + @Override + public void navigateUp() { + scrollOffset = Math.max(0, scrollOffset - 1); + } + + @Override + public void navigateDown() { + scrollOffset++; + } + + @Override + public void onIntegrationChanged() { + steps = Collections.emptyList(); + scrollOffset = 0; + errorMessage = null; + dataLoaded = false; + } + + @Override + public void render(Frame frame, Rect area) { + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info == null) { + renderNoSelection(frame, area); + return; + } + + if (loading.get() && steps.isEmpty()) { + frame.renderWidget( + Paragraph.builder() + .text(Text.from(Line.from(Span.styled(" Loading startup data...", LABEL)))) + .block(Block.builder().borderType(BorderType.ROUNDED) + .title(" Startup Timeline ").build()) + .build(), + area); + return; + } + + if (errorMessage != null && steps.isEmpty()) { + frame.renderWidget( + Paragraph.builder() + .text(Text.from(Line.from( + Span.styled(" " + errorMessage, Style.EMPTY.fg(Color.LIGHT_RED))))) + .block(Block.builder().borderType(BorderType.ROUNDED) + .title(" Startup Timeline ").build()) + .build(), + area); + return; + } + + if (steps.isEmpty()) { + frame.renderWidget( + Paragraph.builder() + .text(Text.from(Line.from( + Span.styled( + " No startup data available. The integration may not have a startup recorder enabled.", + LABEL)))) + .block(Block.builder().borderType(BorderType.ROUNDED) + .title(" Startup Timeline ").build()) + .build(), + area); + return; + } + + String title = String.format(" Startup Timeline — Total: %dms, Steps: %d ", totalDuration, steps.size()); + Block block = Block.builder() + .borderType(BorderType.ROUNDED) + .title(title) + .build(); + Rect inner = block.inner(area); + frame.renderWidget(block, area); + + if (inner.height() < 1 || inner.width() < 10) { + return; + } + + int visibleLines = inner.height(); + int maxScroll = Math.max(0, steps.size() - visibleLines); + scrollOffset = Math.min(scrollOffset, maxScroll); + + int end = Math.min(scrollOffset + visibleLines, steps.size()); + + int barMaxWidth = Math.max(10, inner.width() - 30); + + List lines = new ArrayList<>(); + for (int i = scrollOffset; i < end; i++) { + lines.add(renderStep(steps.get(i), barMaxWidth)); + } + + List hChunks = Layout.horizontal() + .constraints(Constraint.fill(), Constraint.length(1)) + .split(inner); + + frame.renderWidget(Paragraph.builder().text(Text.from(lines)).build(), hChunks.get(0)); + + if (steps.size() > visibleLines) { + scrollbarState + .contentLength(steps.size()) + .viewportContentLength(visibleLines) + .position(scrollOffset); + frame.renderStatefulWidget(Scrollbar.builder().build(), hChunks.get(1), scrollbarState); + } + } + + private Line renderStep(StartupStep step, int maxBarWidth) { + String indent = " ".repeat(step.level); + + boolean isRoot = step.level == 0; + Style bandStyle = isRoot ? LABEL : colorForDuration(step.duration); + + double ratio = maxDuration > 0 ? (double) step.duration / maxDuration : 0; + int barWidth = Math.max(1, (int) Math.round(ratio * maxBarWidth)); + String bar = "█".repeat(barWidth); + + String durationStr = step.duration + "ms"; + String label = step.name != null ? step.name : ""; + if (step.description != null && !step.description.isEmpty() && !step.description.equals(label)) { + label += " " + step.description; + } + + int pad = Math.max(1, 8 - durationStr.length()); + + return Line.from( + Span.raw(indent), + Span.styled(bar, bandStyle), + Span.raw(" ".repeat(pad)), + Span.styled(durationStr, isRoot ? LABEL : VALUE), + Span.styled(" " + label, LABEL)); + } + + private Style colorForDuration(long duration) { + if (maxDurationColor <= minDurationColor) { + return BAND_STYLES[0]; + } + double ratio = (Math.log1p(duration) - Math.log1p(minDurationColor)) + / (Math.log1p(maxDurationColor) - Math.log1p(minDurationColor)); + int bandIndex = Math.min((int) (ratio * 5), 4); + return BAND_STYLES[bandIndex]; + } + + @Override + public void renderFooter(List spans) { + hint(spans, "Esc", "back"); + hint(spans, "↑↓", "scroll"); + hint(spans, "PgUp/Dn", "page"); + hintLast(spans, "F5", "reload"); + } + + private void loadStartupData() { + if (ctx.selectedPid == null || ctx.runner == null) { + return; + } + if (!loading.compareAndSet(false, true)) { + return; + } + + String pid = ctx.selectedPid; + ctx.runner.scheduler().execute(() -> { + try { + Path outputFile = ctx.getOutputFile(pid); + PathUtils.deleteFile(outputFile); + + JsonObject root = new JsonObject(); + root.put("action", "startup-recorder"); + + Path actionFile = ctx.getActionFile(pid); + PathUtils.writeTextSafely(root.toJson(), actionFile); + + JsonObject jo = pollJsonResponse(outputFile, 5000); + PathUtils.deleteFile(outputFile); + + if (jo == null) { + applyResult(Collections.emptyList(), "No response from integration"); + return; + } + + JsonArray stepsArr = (JsonArray) jo.get("steps"); + if (stepsArr == null || stepsArr.isEmpty()) { + applyResult(Collections.emptyList(), "No startup steps available"); + return; + } + + List parsed = new ArrayList<>(); + for (Object o : stepsArr) { + JsonObject sj = (JsonObject) o; + StartupStep s = new StartupStep(); + s.id = sj.getIntegerOrDefault("id", 0); + s.parentId = sj.getIntegerOrDefault("parentId", 0); + s.level = sj.getIntegerOrDefault("level", 0); + s.name = sj.getString("name"); + s.type = sj.getString("type"); + s.description = sj.getString("description"); + s.beginTime = TuiHelper.objToLong(sj.get("beginTime")); + s.duration = TuiHelper.objToLong(sj.get("duration")); + parsed.add(s); + } + + applyResult(parsed, null); + } catch (Exception e) { + applyResult(Collections.emptyList(), "Error: " + e.getMessage()); + } finally { + loading.set(false); + } + }); + } + + private void applyResult(List parsed, String error) { + if (ctx.runner == null) { + return; + } + ctx.runner.runOnRenderThread(() -> { + steps = parsed; + errorMessage = error; + dataLoaded = true; + + if (!steps.isEmpty()) { + totalDuration = steps.stream().mapToLong(s -> s.duration).max().orElse(0); + maxDuration = totalDuration; + minDurationColor + = steps.stream().filter(s -> s.level > 0).mapToLong(s -> s.duration).min().orElse(0); + maxDurationColor + = steps.stream().filter(s -> s.level > 0).mapToLong(s -> s.duration).max().orElse(0); + } + }); + } + + @Override + public SelectionContext getSelectionContext() { + return null; + } + + static class StartupStep { + int id; + int parentId; + int level; + String name; + String type; + String description; + long beginTime; + long duration; + } +} From f32a6731cfba9ce24af7b6aeea7f5d3feff84e7e Mon Sep 17 00:00:00 2001 From: Claus Ibsen Date: Fri, 29 May 2026 20:32:21 +0200 Subject: [PATCH 3/4] CAMEL-23615: camel-jbang - TUI add Configuration tab Co-Authored-By: Claude --- .../jbang/core/commands/tui/CamelMonitor.java | 34 ++- .../core/commands/tui/ConfigurationTab.java | 240 ++++++++++++++++++ .../core/commands/tui/IntegrationInfo.java | 1 + 3 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfigurationTab.java 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 d2bb3d1e858dc..6adcb2264f45f 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 @@ -277,6 +277,7 @@ public class CamelMonitor extends CamelCommand { private ErrorsTab errorsTab; private MetricsTab metricsTab; private StartupTab startupTab; + private ConfigurationTab configurationTab; // "More" dropdown state private boolean showMorePopup; @@ -334,6 +335,7 @@ public Integer doCall() throws Exception { errorsTab = new ErrorsTab(ctx); metricsTab = new MetricsTab(ctx); startupTab = new StartupTab(ctx); + configurationTab = new ConfigurationTab(ctx); // Initial data load (synchronous before TUI starts) refreshDataSync(); @@ -427,7 +429,7 @@ private boolean handleEvent(Event event, TuiRunner runner) { return true; } if (ke.isDown()) { - morePopupState.selectNext(3); + morePopupState.selectNext(4); return true; } if (ke.isConfirm()) { @@ -436,8 +438,9 @@ private boolean handleEvent(Event event, TuiRunner runner) { if (sel != null) { activeMoreTab = switch (sel) { case 0 -> circuitBreakerTab; - case 1 -> consumersTab; - case 2 -> startupTab; + case 1 -> configurationTab; + case 2 -> consumersTab; + case 3 -> startupTab; default -> null; }; if (activeMoreTab != null) { @@ -1147,7 +1150,7 @@ private void renderContent(Frame frame, Rect area) { private void renderMorePopup(Frame frame, Rect area) { int popupW = 22; - int popupH = 5; + int popupH = 6; // Position just below the "0 More▾" tab label int dividerW = CharWidth.of(" | "); int tabBarX = 0; @@ -1169,6 +1172,7 @@ private void renderMorePopup(Frame frame, Rect area) { ListItem[] items = { ListItem.from(" Circuit Breaker"), + ListItem.from(" Configuration"), ListItem.from(" Consumers"), ListItem.from(" Startup"), }; @@ -3331,6 +3335,28 @@ private IntegrationInfo parseIntegration(ProcessHandle ph, JsonObject root) { parseHttpEndpoints(phpObj, "managementEndpoints", true, info); } + // Parse configuration properties (from PropertiesDevConsole) + JsonObject propsObj = (JsonObject) root.get("properties"); + if (propsObj != null) { + JsonArray propArr = (JsonArray) propsObj.get("properties"); + if (propArr != null) { + for (Object p : propArr) { + JsonObject pj = (JsonObject) p; + String key = pj.getString("key"); + if (key != null && !key.startsWith("camel.jbang.")) { + ConfigurationTab.ConfigProperty cp = new ConfigurationTab.ConfigProperty(); + cp.key = key; + cp.value = objToString(pj.get("value")); + cp.defaultValue = pj.getString("defaultValue"); + cp.source = pj.getString("source"); + cp.location = pj.getString("location"); + info.configProperties.add(cp); + } + } + info.configProperties.sort(ConfigurationTab::compareCamelFirst); + } + } + return info; } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfigurationTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfigurationTab.java new file mode 100644 index 0000000000000..c55daaa49e9bf --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfigurationTab.java @@ -0,0 +1,240 @@ +/* + * 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.util.ArrayList; +import java.util.List; + +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.scrollbar.Scrollbar; +import dev.tamboui.widgets.scrollbar.ScrollbarState; + +import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.*; + +class ConfigurationTab implements MonitorTab { + + private static final Style KEY_STYLE = Style.EMPTY.fg(Color.CYAN); + private static final Style VALUE_STYLE = Style.EMPTY.fg(Color.WHITE); + private static final Style SECRET_STYLE = Style.EMPTY.fg(Color.DARK_GRAY); + private static final Style SOURCE_STYLE = Style.EMPTY.dim(); + + private final MonitorContext ctx; + private final ScrollbarState scrollbarState = new ScrollbarState(); + private int scrollOffset; + + ConfigurationTab(MonitorContext ctx) { + this.ctx = ctx; + } + + @Override + public boolean handleKeyEvent(KeyEvent ke) { + if (ke.isUp()) { + scrollOffset = Math.max(0, scrollOffset - 1); + return true; + } + if (ke.isDown()) { + scrollOffset++; + return true; + } + if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) { + scrollOffset = Math.max(0, scrollOffset - 20); + return true; + } + if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) { + scrollOffset += 20; + return true; + } + if (ke.isHome()) { + scrollOffset = 0; + return true; + } + if (ke.isEnd()) { + scrollOffset = Integer.MAX_VALUE; + return true; + } + return false; + } + + @Override + public boolean handleEscape() { + return false; + } + + @Override + public void navigateUp() { + scrollOffset = Math.max(0, scrollOffset - 1); + } + + @Override + public void navigateDown() { + scrollOffset++; + } + + @Override + public void render(Frame frame, Rect area) { + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info == null) { + renderNoSelection(frame, area); + return; + } + + List props = info.configProperties; + if (props.isEmpty()) { + frame.renderWidget( + Paragraph.builder() + .text(Text.from(Line.from( + Span.styled(" No configuration properties available.", Style.EMPTY.dim())))) + .block(Block.builder().borderType(BorderType.ROUNDED) + .title(" Configuration ").build()) + .build(), + area); + return; + } + + // Find divider position: index of first non-camel property + int dividerIndex = -1; + for (int i = 0; i < props.size(); i++) { + if (!props.get(i).key.startsWith("camel.")) { + dividerIndex = i; + break; + } + } + boolean hasDivider = dividerIndex > 0 && dividerIndex < props.size(); + int totalLines = props.size() + (hasDivider ? 1 : 0); + + String title = String.format(" Configuration — %d properties ", props.size()); + Block block = Block.builder() + .borderType(BorderType.ROUNDED) + .title(title) + .build(); + Rect inner = block.inner(area); + frame.renderWidget(block, area); + + if (inner.height() < 1 || inner.width() < 10) { + return; + } + + int visibleLines = inner.height(); + int maxScroll = Math.max(0, totalLines - visibleLines); + scrollOffset = Math.min(scrollOffset, maxScroll); + + // Compute max key length across visible properties for alignment + int maxKeyLen = 0; + for (ConfigProperty p : props) { + maxKeyLen = Math.max(maxKeyLen, p.key.length()); + } + int keyWidth = Math.min(maxKeyLen, inner.width() / 2); + + // Build visible lines, inserting divider at the right display position + List lines = new ArrayList<>(); + int displayRow = 0; + for (int i = 0; i < props.size() && lines.size() < visibleLines; i++) { + if (hasDivider && i == dividerIndex) { + if (displayRow >= scrollOffset) { + String divText = "─".repeat(Math.max(1, inner.width() - 2)); + lines.add(Line.from(Span.styled(" " + divText, Style.EMPTY.dim()))); + } + displayRow++; + if (lines.size() >= visibleLines) { + break; + } + } + if (displayRow >= scrollOffset) { + lines.add(renderProperty(props.get(i), keyWidth)); + } + displayRow++; + } + + List hChunks = Layout.horizontal() + .constraints(Constraint.fill(), Constraint.length(1)) + .split(inner); + + frame.renderWidget(Paragraph.builder().text(Text.from(lines)).build(), hChunks.get(0)); + + if (totalLines > visibleLines) { + scrollbarState + .contentLength(totalLines) + .viewportContentLength(visibleLines) + .position(scrollOffset); + frame.renderStatefulWidget(Scrollbar.builder().build(), hChunks.get(1), scrollbarState); + } + } + + private Line renderProperty(ConfigProperty prop, int keyWidth) { + String key = prop.key; + if (key.length() > keyWidth) { + key = key.substring(0, keyWidth - 1) + "…"; + } else { + key = String.format("%-" + keyWidth + "s", key); + } + + boolean secret = "xxxxxx".equals(prop.value); + Style valStyle = secret ? SECRET_STYLE : VALUE_STYLE; + String value = prop.value != null ? prop.value : ""; + + List spans = new ArrayList<>(); + spans.add(Span.styled(" " + key + " ", KEY_STYLE)); + spans.add(Span.styled(value, valStyle)); + if (prop.source != null && !prop.source.isEmpty()) { + spans.add(Span.styled(" [" + prop.source + "]", SOURCE_STYLE)); + } + + return Line.from(spans); + } + + @Override + public void renderFooter(List spans) { + hint(spans, "Esc", "back"); + hint(spans, "↑↓", "scroll"); + hintLast(spans, "PgUp/Dn", "page"); + } + + @Override + public SelectionContext getSelectionContext() { + return null; + } + + static int compareCamelFirst(ConfigProperty a, ConfigProperty b) { + boolean aCamel = a.key.startsWith("camel."); + boolean bCamel = b.key.startsWith("camel."); + if (aCamel != bCamel) { + return aCamel ? -1 : 1; + } + return a.key.compareToIgnoreCase(b.key); + } + + static class ConfigProperty { + String key; + String value; + String defaultValue; + String source; + String location; + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java index 8b2d78db5d054..21bfc0e1b7ea1 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java @@ -67,6 +67,7 @@ class IntegrationInfo { final List errors = new ArrayList<>(); final List meters = new ArrayList<>(); final List httpEndpoints = new ArrayList<>(); + final List configProperties = new ArrayList<>(); String httpServer; String readmeFiles; } From 34c74fb91b9c6c8ee8e3a7ed1a265b06d8b95933 Mon Sep 17 00:00:00 2001 From: Claus Ibsen Date: Fri, 29 May 2026 20:39:14 +0200 Subject: [PATCH 4/4] CAMEL-23615: camel-jbang - TUI fix More popup to overlay current tab and remember selection Co-Authored-By: Claude --- .../dsl/jbang/core/commands/tui/CamelMonitor.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 6adcb2264f45f..b51bb8b27a895 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 @@ -283,6 +283,7 @@ public class CamelMonitor extends CamelCommand { private boolean showMorePopup; private final ListState morePopupState = new ListState(); private MonitorTab activeMoreTab; + private int lastMoreSelection; private Line[] currentTabLabels; private ClassLoader classLoader; @@ -436,6 +437,7 @@ private boolean handleEvent(Event event, TuiRunner runner) { showMorePopup = false; Integer sel = morePopupState.selected(); if (sel != null) { + lastMoreSelection = sel; activeMoreTab = switch (sel) { case 0 -> circuitBreakerTab; case 1 -> configurationTab; @@ -444,6 +446,8 @@ private boolean handleEvent(Event event, TuiRunner runner) { default -> null; }; if (activeMoreTab != null) { + selectCurrentIntegration(); + tabsState.select(TAB_MORE); activeMoreTab.onTabSelected(); } } @@ -792,8 +796,10 @@ private boolean handleTabKey(int tab) { errorsTab.onTabSelected(); } if (tab == TAB_MORE) { - showMorePopup = true; - tabsState.select(tab); + showMorePopup = !showMorePopup; + if (showMorePopup) { + morePopupState.select(lastMoreSelection); + } return true; } showMorePopup = false;