This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 68e4d6361ad3 CAMEL-23615: camel-jbang - TUI add Metrics tab with
dashboard, table, and raw view (#23647)
68e4d6361ad3 is described below
commit 68e4d6361ad3bb96d9ed77061a19a301e51be79b
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri May 29 20:59:32 2026 +0200
CAMEL-23615: camel-jbang - TUI add Metrics tab with dashboard, table, and
raw view (#23647)
* CAMEL-23615: camel-jbang - TUI add Metrics tab with dashboard, table, and
raw view
Co-Authored-By: Claude <[email protected]>
* CAMEL-23615: camel-jbang - TUI add Startup Timeline tab
Co-Authored-By: Claude <[email protected]>
* CAMEL-23615: camel-jbang - TUI add Configuration tab
Co-Authored-By: Claude <[email protected]>
* CAMEL-23615: camel-jbang - TUI fix More popup to overlay current tab and
remember selection
Co-Authored-By: Claude <[email protected]>
---------
Co-authored-by: Claude <[email protected]>
---
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 258 +++++-
.../jbang/core/commands/tui/ConfigurationTab.java | 240 ++++++
.../jbang/core/commands/tui/IntegrationInfo.java | 2 +
.../dsl/jbang/core/commands/tui/MetricsTab.java | 890 +++++++++++++++++++++
.../core/commands/tui/MicrometerMeterInfo.java | 41 +
.../dsl/jbang/core/commands/tui/StartupTab.java | 363 +++++++++
6 files changed, 1765 insertions(+), 29 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 f728deed7dd6..b51bb8b27a89 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.barchart.BarGroup;
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,16 @@ public class CamelMonitor extends CamelCommand {
private HistoryTab historyTab;
private CircuitBreakerTab circuitBreakerTab;
private ErrorsTab errorsTab;
+ private MetricsTab metricsTab;
+ private StartupTab startupTab;
+ private ConfigurationTab configurationTab;
+
+ // "More" dropdown state
+ private boolean showMorePopup;
+ private final ListState morePopupState = new ListState();
+ private MonitorTab activeMoreTab;
+ private int lastMoreSelection;
+ private Line[] currentTabLabels;
private ClassLoader classLoader;
@@ -320,6 +334,9 @@ public class CamelMonitor extends CamelCommand {
historyTab = new HistoryTab(ctx, traces, traceFilePositions);
circuitBreakerTab = new CircuitBreakerTab(ctx, cbSuccessHistory,
cbFailHistory);
errorsTab = new ErrorsTab(ctx);
+ metricsTab = new MetricsTab(ctx);
+ startupTab = new StartupTab(ctx);
+ configurationTab = new ConfigurationTab(ctx);
// Initial data load (synchronous before TUI starts)
refreshDataSync();
@@ -402,6 +419,42 @@ public class CamelMonitor extends CamelCommand {
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(4);
+ return true;
+ }
+ if (ke.isConfirm()) {
+ showMorePopup = false;
+ Integer sel = morePopupState.selected();
+ if (sel != null) {
+ lastMoreSelection = sel;
+ activeMoreTab = switch (sel) {
+ case 0 -> circuitBreakerTab;
+ case 1 -> configurationTab;
+ case 2 -> consumersTab;
+ case 3 -> startupTab;
+ default -> null;
+ };
+ if (activeMoreTab != null) {
+ selectCurrentIntegration();
+ tabsState.select(TAB_MORE);
+ 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 +516,10 @@ public class CamelMonitor extends CamelCommand {
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 +786,6 @@ public class CamelMonitor extends CamelCommand {
}
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 +795,14 @@ public class CamelMonitor extends CamelCommand {
}
errorsTab.onTabSelected();
}
+ if (tab == TAB_MORE) {
+ showMorePopup = !showMorePopup;
+ if (showMorePopup) {
+ morePopupState.select(lastMoreSelection);
+ }
+ return true;
+ }
+ showMorePopup = false;
tabsState.select(tab);
return true;
}
@@ -968,7 +1026,6 @@ public class CamelMonitor extends CamelCommand {
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 +1041,8 @@ public class CamelMonitor extends CamelCommand {
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 +1053,10 @@ public class CamelMonitor extends CamelCommand {
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 +1090,6 @@ public class CamelMonitor extends CamelCommand {
if (routeCount > 0) {
badgeTexts[TAB_ROUTES] = "(" + routeCount + ")";
}
- if (consumerCount > 0) {
- badgeTexts[TAB_CONSUMERS] = "(" + consumerCount + ")";
- }
if (endpointCount > 0) {
badgeTexts[TAB_ENDPOINTS] = "(" + endpointCount + ")";
}
@@ -1051,11 +1108,12 @@ public class CamelMonitor extends CamelCommand {
} 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 +1148,64 @@ public class CamelMonitor extends CamelCommand {
} 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 = 6;
+ // 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(" Configuration"),
+ ListItem.from(" Consumers"),
+ ListItem.from(" Startup"),
+ };
+ 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 +1977,23 @@ public class CamelMonitor extends CamelCommand {
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<Span> 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<Span> f2Spans = new ArrayList<>();
+ hint(f2Spans, "F2", "actions");
+ spans.addAll(insertPos, f2Spans);
+ } else {
+ renderOverviewFooter(spans);
+ }
}
List<Span> rightSpans = new ArrayList<>();
@@ -3178,6 +3287,16 @@ public class CamelMonitor extends CamelCommand {
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) {
@@ -3222,6 +3341,28 @@ public class CamelMonitor extends CamelCommand {
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;
}
@@ -3289,6 +3430,65 @@ public class CamelMonitor extends CamelCommand {
}
}
+ @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<String, Object>
values, Map<String, String> 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/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 000000000000..c55daaa49e9b
--- /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<ConfigProperty> 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<Line> 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<Rect> 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<Span> 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<Span> 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 018fabc1a6f1..21bfc0e1b7ea 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,7 +65,9 @@ class IntegrationInfo {
final List<CircuitBreakerInfo> circuitBreakers = new ArrayList<>();
int errorCount;
final List<ErrorInfo> errors = new ArrayList<>();
+ final List<MicrometerMeterInfo> meters = new ArrayList<>();
final List<HttpEndpointInfo> httpEndpoints = new ArrayList<>();
+ final List<ConfigurationTab.ConfigProperty> configProperties = 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 000000000000..c4b26037fc2c
--- /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<String> 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<Rect> 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<Rect> 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<MicrometerMeterInfo> meters) {
+ List<Line> 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<MicrometerMeterInfo> 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<MicrometerMeterInfo> 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<MicrometerMeterInfo> meters) {
+ List<Line> 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<MicrometerMeterInfo> 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<MicrometerMeterInfo> filtered = info.meters;
+ if (!"all".equals(filterType)) {
+ filtered = filtered.stream()
+ .filter(m -> filterType.equals(m.type))
+ .collect(Collectors.toList());
+ }
+
+ List<MicrometerMeterInfo> sorted = new ArrayList<>(filtered);
+ sorted.sort(this::sortMeter);
+
+ List<Row> 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<Line> visible = new ArrayList<>();
+ for (int i = rawScroll; i < end; i++) {
+ visible.add(colorPrometheusLine(rawLines.get(i)));
+ }
+
+ List<Rect> 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<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
+ String ct =
response.headers().firstValue("Content-Type").orElse(null);
+ List<String> 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<String> 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<Span> 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<MicrometerMeterInfo>
meters, String name) {
+ for (MicrometerMeterInfo m : meters) {
+ if (name.equals(m.name)) {
+ return m;
+ }
+ }
+ return null;
+ }
+
+ private static MicrometerMeterInfo findMeter(
+ List<MicrometerMeterInfo> 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<MicrometerMeterInfo>
findMeters(List<MicrometerMeterInfo> 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<MicrometerMeterInfo> meters, String
name) {
+ MicrometerMeterInfo m = findMeter(meters, name);
+ return m != null && m.count != null ? m.count : 0;
+ }
+
+ private static double gaugeValue(List<MicrometerMeterInfo> meters, String
name) {
+ MicrometerMeterInfo m = findMeter(meters, name);
+ return m != null && m.value != null ? m.value : -1;
+ }
+
+ private static double gaugeValue(List<MicrometerMeterInfo> 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<MicrometerMeterInfo> 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<MicrometerMeterInfo> filtered = info.meters;
+ if (!"all".equals(filterType)) {
+ filtered = filtered.stream()
+ .filter(m -> filterType.equals(m.type))
+ .collect(Collectors.toList());
+ }
+ List<MicrometerMeterInfo> sorted = new ArrayList<>(filtered);
+ sorted.sort(this::sortMeter);
+ List<String> 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 000000000000..2d8fe7f85601
--- /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<String[]> 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;
+}
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 000000000000..6854935115d7
--- /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<StartupStep> 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<Line> lines = new ArrayList<>();
+ for (int i = scrollOffset; i < end; i++) {
+ lines.add(renderStep(steps.get(i), barMaxWidth));
+ }
+
+ List<Rect> 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<Span> 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<StartupStep> 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<StartupStep> 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;
+ }
+}