You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by gn...@apache.org on 2026/03/23 14:22:11 UTC
(camel) 01/01: CAMEL-23226: Add TUI dashboards for Camel JBang
This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch Guillaume-Nodet/camel-tui
in repository https://gitbox.apache.org/repos/asf/camel.git
commit df9143e6717488ea4cf9a3535d23d0b10c57f8a5
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Mon Mar 23 15:21:51 2026 +0100
CAMEL-23226: Add TUI dashboards for Camel JBang
- Add `camel monitor` full-screen TUI dashboard with tabs for overview,
routes, health checks, endpoints, and log viewer
- Add `camel top tui` htop-style route performance dashboard with
sortable columns and live refresh
- Add `camel log tui` scrollable log viewer with level filtering,
follow mode, and grep support
- Add `camel trace tui` exchange trace viewer with detail panel showing
headers, body, and exchange properties
- Add `camel health` TUI health check dashboard with UP/DOWN indicators
and memory gauge
- Add `camel catalog tui` interactive component catalog browser with
search/filter and component detail view
- Upgrade JLine from 4.0.7 to 4.0.9
- Add TamboUI dependencies for terminal UI rendering
- Use TamboUI's built-in JLine 3 backend (compatible with JLine 4.0.9)
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
dsl/camel-jbang/camel-jbang-core/pom.xml | 28 +
.../dsl/jbang/core/commands/CamelJBangMain.java | 15 +-
.../jbang/core/commands/tui/CamelCatalogTui.java | 697 +++++++++++
.../jbang/core/commands/tui/CamelHealthTui.java | 498 ++++++++
.../dsl/jbang/core/commands/tui/CamelLogTui.java | 498 ++++++++
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 1301 ++++++++++++++++++++
.../dsl/jbang/core/commands/tui/CamelTopTui.java | 488 ++++++++
.../dsl/jbang/core/commands/tui/CamelTraceTui.java | 635 ++++++++++
parent/pom.xml | 2 +-
9 files changed, 4157 insertions(+), 5 deletions(-)
diff --git a/dsl/camel-jbang/camel-jbang-core/pom.xml b/dsl/camel-jbang/camel-jbang-core/pom.xml
index 31dccd48d50d..58241f214dc0 100644
--- a/dsl/camel-jbang/camel-jbang-core/pom.xml
+++ b/dsl/camel-jbang/camel-jbang-core/pom.xml
@@ -175,8 +175,36 @@
<version>${awaitility-version}</version>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>dev.tamboui</groupId>
+ <artifactId>tamboui-tui</artifactId>
+ <version>0.2.0-SNAPSHOT</version>
+ </dependency>
+ <dependency>
+ <groupId>dev.tamboui</groupId>
+ <artifactId>tamboui-widgets</artifactId>
+ <version>0.2.0-SNAPSHOT</version>
+ </dependency>
+ <dependency>
+ <groupId>dev.tamboui</groupId>
+ <artifactId>tamboui-jline3-backend</artifactId>
+ <version>0.2.0-SNAPSHOT</version>
+ </dependency>
</dependencies>
+ <repositories>
+ <repository>
+ <id>sonatype-snapshots</id>
+ <url>https://central.sonatype.com/repository/maven-snapshots/</url>
+ <snapshots>
+ <enabled>true</enabled>
+ </snapshots>
+ <releases>
+ <enabled>false</enabled>
+ </releases>
+ </repository>
+ </repositories>
+
<build>
<plugins>
<plugin>
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
index bd03af9ee39c..92febdab4659 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
@@ -49,6 +49,7 @@ import org.apache.camel.dsl.jbang.core.commands.plugin.PluginCommand;
import org.apache.camel.dsl.jbang.core.commands.plugin.PluginDelete;
import org.apache.camel.dsl.jbang.core.commands.plugin.PluginGet;
import org.apache.camel.dsl.jbang.core.commands.process.*;
+import org.apache.camel.dsl.jbang.core.commands.tui.*;
import org.apache.camel.dsl.jbang.core.commands.update.UpdateCommand;
import org.apache.camel.dsl.jbang.core.commands.update.UpdateList;
import org.apache.camel.dsl.jbang.core.commands.update.UpdateRun;
@@ -96,7 +97,8 @@ public class CamelJBangMain implements Callable<Integer> {
.addSubcommand("kamelet", new CommandLine(new CatalogKamelet(this)))
.addSubcommand("transformer", new CommandLine(new CatalogTransformer(this)))
.addSubcommand("language", new CommandLine(new CatalogLanguage(this)))
- .addSubcommand("other", new CommandLine(new CatalogOther(this))))
+ .addSubcommand("other", new CommandLine(new CatalogOther(this)))
+ .addSubcommand("tui", new CommandLine(new CamelCatalogTui(this))))
.addSubcommand("cmd", new CommandLine(new CamelAction(this))
.addSubcommand("browse", new CommandLine(new CamelBrowseAction(this)))
.addSubcommand("disable-processor", new CommandLine(new CamelProcessorDisableAction(this)))
@@ -177,7 +179,9 @@ public class CamelJBangMain implements Callable<Integer> {
.addSubcommand("stop", new CommandLine(new InfraStop(this))))
.addSubcommand("init", new CommandLine(new Init(this)))
.addSubcommand("jolokia", new CommandLine(new Jolokia(this)))
- .addSubcommand("log", new CommandLine(new CamelLogAction(this)))
+ .addSubcommand("health", new CommandLine(new CamelHealthTui(this)))
+ .addSubcommand("log", new CommandLine(new CamelLogAction(this))
+ .addSubcommand("tui", new CommandLine(new CamelLogTui(this))))
.addSubcommand("nano", new CommandLine(new Nano(this)))
.addSubcommand("plugin", new CommandLine(new PluginCommand(this))
.addSubcommand("add", new CommandLine(new PluginAdd(this)))
@@ -187,6 +191,7 @@ public class CamelJBangMain implements Callable<Integer> {
.addSubcommand("run", new CommandLine(new Run(this)))
.addSubcommand("sbom", new CommandLine(new SBOMGenerator(this)))
.addSubcommand("script", new CommandLine(new Script(this)))
+ .addSubcommand("monitor", new CommandLine(new CamelMonitor(this)))
.addSubcommand("shell", new CommandLine(new Shell(this)))
.addSubcommand("stop", new CommandLine(new StopProcess(this)))
.addSubcommand("top", new CommandLine(new CamelTop(this))
@@ -194,8 +199,10 @@ public class CamelJBangMain implements Callable<Integer> {
.addSubcommand("group", new CommandLine(new CamelRouteGroupTop(this)))
.addSubcommand("processor", new CommandLine(new CamelProcessorTop(this)))
.addSubcommand("route", new CommandLine(new CamelRouteTop(this)))
- .addSubcommand("source", new CommandLine(new CamelSourceTop(this))))
- .addSubcommand("trace", new CommandLine(new CamelTraceAction(this)))
+ .addSubcommand("source", new CommandLine(new CamelSourceTop(this)))
+ .addSubcommand("tui", new CommandLine(new CamelTopTui(this))))
+ .addSubcommand("trace", new CommandLine(new CamelTraceAction(this))
+ .addSubcommand("tui", new CommandLine(new CamelTraceTui(this))))
.addSubcommand("transform", new CommandLine(new TransformCommand(this))
.addSubcommand("dataweave", new CommandLine(new TransformDataWeave(this)))
.addSubcommand("message", new CommandLine(new TransformMessageAction(this)))
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java
new file mode 100644
index 000000000000..2c85d1c113a1
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java
@@ -0,0 +1,697 @@
+/*
+ * 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.Collections;
+import java.util.List;
+import java.util.Map;
+
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+import dev.tamboui.style.Overflow;
+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.TuiRunner;
+import dev.tamboui.tui.event.Event;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import dev.tamboui.widgets.table.Cell;
+import dev.tamboui.widgets.table.Row;
+import dev.tamboui.widgets.table.Table;
+import dev.tamboui.widgets.table.TableState;
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.dsl.jbang.core.commands.CamelCommand;
+import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import picocli.CommandLine.Command;
+
+@Command(name = "catalog-tui",
+ description = "Interactive TUI catalog browser",
+ sortOptions = false)
+public class CamelCatalogTui extends CamelCommand {
+
+ private static final int FOCUS_LIST = 0;
+ private static final int FOCUS_DETAILS = 1;
+
+ // Catalog data
+ private List<ComponentInfo> allComponents = Collections.emptyList();
+ private List<ComponentInfo> filteredComponents = Collections.emptyList();
+
+ // UI state
+ private final TableState listTableState = new TableState();
+ private final TableState optionsTableState = new TableState();
+ private int focus = FOCUS_LIST;
+ private boolean searching;
+ private final StringBuilder searchText = new StringBuilder();
+ private int detailScroll;
+
+ // Selected component details
+ private ComponentDetail selectedDetail;
+
+ public CamelCatalogTui(CamelJBangMain main) {
+ super(main);
+ }
+
+ @Override
+ public Integer doCall() throws Exception {
+ try {
+ Class.forName("dev.tamboui.tui.event.KeyModifiers");
+ Class.forName("dev.tamboui.tui.event.KeyEvent");
+ Class.forName("dev.tamboui.tui.event.KeyCode");
+ Class.forName("picocli.CommandLine$IExitCodeGenerator");
+ } catch (ClassNotFoundException e) {
+ // ignore
+ }
+
+ loadCatalog();
+
+ try (var tui = TuiRunner.create()) {
+ sun.misc.Signal.handle(new sun.misc.Signal("INT"), sig -> tui.quit());
+ tui.run(this::handleEvent, this::render);
+ }
+ return 0;
+ }
+
+ // ---- Catalog Loading ----
+
+ @SuppressWarnings("unchecked")
+ private void loadCatalog() {
+ CamelCatalog catalog = new DefaultCamelCatalog();
+ List<String> names = catalog.findComponentNames();
+ Collections.sort(names);
+
+ allComponents = new ArrayList<>();
+ for (String name : names) {
+ try {
+ String json = catalog.componentJSonSchema(name);
+ if (json == null) {
+ continue;
+ }
+ JsonObject root = (JsonObject) Jsoner.deserialize(json);
+ JsonObject component = (JsonObject) root.get("component");
+ if (component == null) {
+ continue;
+ }
+
+ ComponentInfo info = new ComponentInfo();
+ info.name = name;
+ info.title = component.getStringOrDefault("title", name);
+ info.description = component.getStringOrDefault("description", "");
+ info.label = component.getStringOrDefault("label", "");
+ info.groupId = component.getStringOrDefault("groupId", "");
+ info.artifactId = component.getStringOrDefault("artifactId", "");
+ info.version = component.getStringOrDefault("version", "");
+ info.scheme = component.getStringOrDefault("scheme", name);
+ info.syntax = component.getStringOrDefault("syntax", "");
+ info.deprecated = component.getBooleanOrDefault("deprecated", false);
+
+ // Parse component properties
+ JsonObject properties = (JsonObject) root.get("properties");
+ if (properties != null) {
+ for (Map.Entry<String, Object> entry : properties.entrySet()) {
+ JsonObject prop = (JsonObject) entry.getValue();
+ OptionInfo opt = parseOption(entry.getKey(), prop);
+ opt.kind = "property";
+ info.componentOptions.add(opt);
+ }
+ }
+
+ // Parse endpoint properties (headers in newer catalogs, but "properties" is the standard)
+ // The endpoint options are typically under "properties" for the endpoint
+ // In the catalog JSON, component-level options have "kind":"property"
+ // and endpoint options have "kind":"path" or "kind":"parameter"
+ // So we re-scan and separate them
+ info.componentOptions.clear();
+ info.endpointOptions.clear();
+ if (properties != null) {
+ for (Map.Entry<String, Object> entry : properties.entrySet()) {
+ JsonObject prop = (JsonObject) entry.getValue();
+ OptionInfo opt = parseOption(entry.getKey(), prop);
+ String kind = prop.getStringOrDefault("kind", "parameter");
+ opt.kind = kind;
+ if ("property".equals(kind)) {
+ info.componentOptions.add(opt);
+ } else {
+ info.endpointOptions.add(opt);
+ }
+ }
+ }
+
+ allComponents.add(info);
+ } catch (Exception e) {
+ // skip unparseable components
+ }
+ }
+
+ applyFilter();
+ if (!filteredComponents.isEmpty()) {
+ listTableState.select(0);
+ updateSelectedDetail();
+ }
+ }
+
+ private OptionInfo parseOption(String name, JsonObject prop) {
+ OptionInfo opt = new OptionInfo();
+ opt.name = name;
+ opt.type = prop.getStringOrDefault("type", "");
+ opt.required = prop.getBooleanOrDefault("required", false);
+ opt.defaultValue = prop.getStringOrDefault("defaultValue", "");
+ opt.description = prop.getStringOrDefault("description", "");
+ opt.group = prop.getStringOrDefault("group", "");
+ opt.enumValues = "";
+ Object enums = prop.get("enum");
+ if (enums instanceof JsonArray arr) {
+ List<String> vals = new ArrayList<>();
+ for (Object v : arr) {
+ vals.add(String.valueOf(v));
+ }
+ opt.enumValues = String.join(", ", vals);
+ }
+ return opt;
+ }
+
+ private void applyFilter() {
+ String filter = searchText.toString().toLowerCase();
+ if (filter.isEmpty()) {
+ filteredComponents = new ArrayList<>(allComponents);
+ } else {
+ filteredComponents = new ArrayList<>();
+ for (ComponentInfo c : allComponents) {
+ if (c.name.toLowerCase().contains(filter)
+ || c.title.toLowerCase().contains(filter)
+ || c.description.toLowerCase().contains(filter)
+ || c.label.toLowerCase().contains(filter)) {
+ filteredComponents.add(c);
+ }
+ }
+ }
+ // Reset selection
+ if (!filteredComponents.isEmpty()) {
+ listTableState.select(0);
+ } else {
+ listTableState.clearSelection();
+ }
+ updateSelectedDetail();
+ }
+
+ private void updateSelectedDetail() {
+ Integer sel = listTableState.selected();
+ if (sel != null && sel >= 0 && sel < filteredComponents.size()) {
+ ComponentInfo info = filteredComponents.get(sel);
+ selectedDetail = new ComponentDetail(info);
+ detailScroll = 0;
+ } else {
+ selectedDetail = null;
+ }
+ }
+
+ // ---- Event Handling ----
+
+ private boolean handleEvent(Event event, TuiRunner runner) {
+ if (event instanceof KeyEvent ke) {
+ // Search mode handling
+ if (searching) {
+ if (ke.isKey(KeyCode.ESCAPE)) {
+ if (searchText.isEmpty()) {
+ searching = false;
+ } else {
+ searchText.setLength(0);
+ applyFilter();
+ }
+ return true;
+ }
+ if (ke.isKey(KeyCode.ENTER)) {
+ searching = false;
+ return true;
+ }
+ if (ke.isKey(KeyCode.BACKSPACE)) {
+ if (!searchText.isEmpty()) {
+ searchText.deleteCharAt(searchText.length() - 1);
+ applyFilter();
+ }
+ return true;
+ }
+ if (ke.isUp()) {
+ listTableState.selectPrevious();
+ updateSelectedDetail();
+ return true;
+ }
+ if (ke.isDown()) {
+ listTableState.selectNext(filteredComponents.size());
+ updateSelectedDetail();
+ return true;
+ }
+ // Typed character
+ if (ke.code() == KeyCode.CHAR) {
+ searchText.append(ke.character());
+ applyFilter();
+ return true;
+ }
+ return true;
+ }
+
+ // Normal mode
+ if (ke.isQuit() || ke.isCharIgnoreCase('q') || ke.isKey(KeyCode.ESCAPE)) {
+ runner.quit();
+ return true;
+ }
+
+ if (ke.isChar('/')) {
+ searching = true;
+ return true;
+ }
+
+ if (ke.isKey(KeyCode.TAB)) {
+ focus = (focus == FOCUS_LIST) ? FOCUS_DETAILS : FOCUS_LIST;
+ return true;
+ }
+
+ if (ke.isUp()) {
+ if (focus == FOCUS_LIST) {
+ listTableState.selectPrevious();
+ updateSelectedDetail();
+ } else {
+ detailScroll = Math.max(0, detailScroll - 1);
+ }
+ return true;
+ }
+ if (ke.isDown()) {
+ if (focus == FOCUS_LIST) {
+ listTableState.selectNext(filteredComponents.size());
+ updateSelectedDetail();
+ } else {
+ detailScroll++;
+ }
+ return true;
+ }
+ if (ke.isKey(KeyCode.PAGE_UP)) {
+ if (focus == FOCUS_DETAILS) {
+ detailScroll = Math.max(0, detailScroll - 20);
+ }
+ return true;
+ }
+ if (ke.isKey(KeyCode.PAGE_DOWN)) {
+ if (focus == FOCUS_DETAILS) {
+ detailScroll += 20;
+ }
+ return true;
+ }
+ if (ke.isKey(KeyCode.ENTER)) {
+ if (focus == FOCUS_LIST) {
+ updateSelectedDetail();
+ focus = FOCUS_DETAILS;
+ }
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // ---- Rendering ----
+
+ private void render(Frame frame) {
+ Rect area = frame.area();
+
+ // Layout: header (3 rows) + content (fill) + footer (1 row)
+ List<Rect> mainChunks = Layout.vertical()
+ .constraints(
+ Constraint.length(3),
+ Constraint.fill(),
+ Constraint.length(1))
+ .split(area);
+
+ renderHeader(frame, mainChunks.get(0));
+ renderContent(frame, mainChunks.get(1));
+ renderFooter(frame, mainChunks.get(2));
+ }
+
+ private void renderHeader(Frame frame, Rect area) {
+ String searchStatus;
+ if (searching) {
+ searchStatus = " Search: " + searchText + "_";
+ } else if (!searchText.isEmpty()) {
+ searchStatus = " Filter: \"" + searchText + "\"";
+ } else {
+ searchStatus = "";
+ }
+
+ Line titleLine = Line.from(
+ Span.styled(" Camel Catalog", Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)).bold()),
+ Span.raw(" "),
+ Span.styled(filteredComponents.size() + "/" + allComponents.size() + " components",
+ Style.create().fg(Color.CYAN)),
+ Span.styled(searchStatus, Style.create().fg(Color.YELLOW)));
+
+ Block headerBlock = Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(" Apache Camel ")
+ .build();
+
+ frame.renderWidget(
+ Paragraph.builder().text(Text.from(titleLine)).block(headerBlock).build(),
+ area);
+ }
+
+ private void renderContent(Frame frame, Rect area) {
+ // Split horizontally: left panel (30%) + right panel (70%)
+ List<Rect> chunks = Layout.horizontal()
+ .constraints(
+ Constraint.percentage(30),
+ Constraint.percentage(70))
+ .split(area);
+
+ renderComponentList(frame, chunks.get(0));
+ renderComponentDetails(frame, chunks.get(1));
+ }
+
+ private void renderComponentList(Frame frame, Rect area) {
+ List<Row> rows = new ArrayList<>();
+ for (ComponentInfo comp : filteredComponents) {
+ Style nameStyle = comp.deprecated
+ ? Style.create().fg(Color.RED).dim()
+ : Style.create().fg(Color.CYAN);
+ String label = comp.name;
+ if (comp.deprecated) {
+ label = label + " (deprecated)";
+ }
+ rows.add(Row.from(Cell.from(Span.styled(label, nameStyle))));
+ }
+
+ if (rows.isEmpty()) {
+ rows.add(Row.from(Cell.from(Span.styled("No matching components", Style.create().dim()))));
+ }
+
+ Style borderStyle = focus == FOCUS_LIST
+ ? Style.create().fg(Color.rgb(0xF6, 0x91, 0x23))
+ : Style.create();
+
+ Table table = Table.builder()
+ .rows(rows)
+ .widths(Constraint.fill())
+ .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue())
+ .block(Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .borderStyle(borderStyle)
+ .title(" Components ")
+ .build())
+ .build();
+
+ frame.renderStatefulWidget(table, area, listTableState);
+ }
+
+ private void renderComponentDetails(Frame frame, Rect area) {
+ Style borderStyle = focus == FOCUS_DETAILS
+ ? Style.create().fg(Color.rgb(0xF6, 0x91, 0x23))
+ : Style.create();
+
+ if (selectedDetail == null) {
+ frame.renderWidget(
+ Paragraph.builder()
+ .text(Text.from(Line.from(
+ Span.styled(" Select a component from the list",
+ Style.create().dim()))))
+ .block(Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .borderStyle(borderStyle)
+ .title(" Details ")
+ .build())
+ .build(),
+ area);
+ return;
+ }
+
+ ComponentInfo comp = selectedDetail.info;
+
+ // Split: info area (top) + options table (bottom)
+ List<Rect> chunks = Layout.vertical()
+ .constraints(
+ Constraint.length(9),
+ Constraint.fill())
+ .split(area);
+
+ // Component info panel
+ List<Line> infoLines = new ArrayList<>();
+ infoLines.add(Line.from(
+ Span.styled(" Title: ", Style.create().bold()),
+ Span.styled(comp.title, Style.create().fg(Color.CYAN))));
+ infoLines.add(Line.from(
+ Span.styled(" Scheme: ", Style.create().bold()),
+ Span.raw(comp.scheme)));
+ infoLines.add(Line.from(
+ Span.styled(" Syntax: ", Style.create().bold()),
+ Span.raw(comp.syntax)));
+ infoLines.add(Line.from(
+ Span.styled(" Label: ", Style.create().bold()),
+ Span.styled(comp.label, Style.create().fg(Color.GREEN))));
+ infoLines.add(Line.from(
+ Span.styled(" Maven: ", Style.create().bold()),
+ Span.raw(comp.groupId + ":" + comp.artifactId + ":" + comp.version)));
+ if (comp.deprecated) {
+ infoLines.add(Line.from(
+ Span.styled(" Status: ", Style.create().bold()),
+ Span.styled("DEPRECATED", Style.create().fg(Color.RED).bold())));
+ }
+ infoLines.add(Line.from(Span.raw("")));
+ // Word-wrap description into the available width
+ int descWidth = Math.max(10, chunks.get(0).width() - 4);
+ String desc = comp.description;
+ if (desc.length() > descWidth) {
+ infoLines.add(Line.from(
+ Span.styled(" " + desc.substring(0, descWidth), Style.create().dim())));
+ if (desc.length() > descWidth * 2) {
+ infoLines.add(Line.from(
+ Span.styled(" " + desc.substring(descWidth, descWidth * 2) + "...", Style.create().dim())));
+ } else {
+ infoLines.add(Line.from(
+ Span.styled(" " + desc.substring(descWidth), Style.create().dim())));
+ }
+ } else {
+ infoLines.add(Line.from(Span.styled(" " + desc, Style.create().dim())));
+ }
+
+ frame.renderWidget(
+ Paragraph.builder()
+ .text(Text.from(infoLines))
+ .overflow(Overflow.CLIP)
+ .block(Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .borderStyle(borderStyle)
+ .title(" " + comp.title + " ")
+ .build())
+ .build(),
+ chunks.get(0));
+
+ // Options table
+ renderOptionsTable(frame, chunks.get(1), borderStyle);
+ }
+
+ private void renderOptionsTable(Frame frame, Rect area, Style borderStyle) {
+ if (selectedDetail == null) {
+ return;
+ }
+
+ ComponentInfo comp = selectedDetail.info;
+
+ // Combine all options with section headers
+ List<Row> rows = new ArrayList<>();
+
+ if (!comp.endpointOptions.isEmpty()) {
+ rows.add(Row.from(
+ Cell.from(Span.styled("--- Endpoint Options ---", Style.create().fg(Color.YELLOW).bold())),
+ Cell.from(""),
+ Cell.from(""),
+ Cell.from(""),
+ Cell.from("")));
+ for (OptionInfo opt : comp.endpointOptions) {
+ rows.add(optionToRow(opt));
+ }
+ }
+
+ if (!comp.componentOptions.isEmpty()) {
+ rows.add(Row.from(
+ Cell.from(Span.styled("--- Component Options ---", Style.create().fg(Color.YELLOW).bold())),
+ Cell.from(""),
+ Cell.from(""),
+ Cell.from(""),
+ Cell.from("")));
+ for (OptionInfo opt : comp.componentOptions) {
+ rows.add(optionToRow(opt));
+ }
+ }
+
+ if (rows.isEmpty()) {
+ rows.add(Row.from(
+ Cell.from(Span.styled("No options", Style.create().dim())),
+ Cell.from(""),
+ Cell.from(""),
+ Cell.from(""),
+ Cell.from("")));
+ }
+
+ // Apply scrolling
+ int innerHeight = Math.max(1, area.height() - 3); // border + header
+ int maxScroll = Math.max(0, rows.size() - innerHeight);
+ if (detailScroll > maxScroll) {
+ detailScroll = maxScroll;
+ }
+ List<Row> visibleRows;
+ if (detailScroll > 0 && detailScroll < rows.size()) {
+ int end = Math.min(detailScroll + innerHeight, rows.size());
+ visibleRows = rows.subList(detailScroll, end);
+ } else {
+ visibleRows = rows;
+ }
+
+ Row header = Row.from(
+ Cell.from(Span.styled("NAME", Style.create().bold())),
+ Cell.from(Span.styled("TYPE", Style.create().bold())),
+ Cell.from(Span.styled("REQ", Style.create().bold())),
+ Cell.from(Span.styled("DEFAULT", Style.create().bold())),
+ Cell.from(Span.styled("DESCRIPTION", Style.create().bold())));
+
+ String scrollInfo = rows.size() > innerHeight
+ ? " [" + (detailScroll + 1) + "-" + Math.min(detailScroll + innerHeight, rows.size())
+ + "/" + rows.size() + "] "
+ : " ";
+
+ Table table = Table.builder()
+ .rows(visibleRows)
+ .header(header)
+ .widths(
+ Constraint.length(25),
+ Constraint.length(12),
+ Constraint.length(4),
+ Constraint.length(12),
+ Constraint.fill())
+ .block(Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .borderStyle(borderStyle)
+ .title(" Options" + scrollInfo)
+ .build())
+ .build();
+
+ frame.renderStatefulWidget(table, area, optionsTableState);
+ }
+
+ private Row optionToRow(OptionInfo opt) {
+ Style nameStyle = opt.required
+ ? Style.create().fg(Color.CYAN).bold()
+ : Style.create().fg(Color.CYAN);
+
+ String desc = opt.description;
+ if (!opt.enumValues.isEmpty()) {
+ desc = desc + " [" + opt.enumValues + "]";
+ }
+
+ return Row.from(
+ Cell.from(Span.styled(opt.name, nameStyle)),
+ Cell.from(Span.styled(opt.type, Style.create().dim())),
+ Cell.from(opt.required
+ ? Span.styled("*", Style.create().fg(Color.RED).bold())
+ : Span.raw("")),
+ Cell.from(Span.styled(opt.defaultValue, Style.create().dim())),
+ Cell.from(truncate(desc, 80)));
+ }
+
+ private void renderFooter(Frame frame, Rect area) {
+ Line footer;
+ if (searching) {
+ footer = Line.from(
+ Span.styled(" Type", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" to filter "),
+ Span.styled("Enter", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" confirm "),
+ Span.styled("Esc", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" clear/cancel "),
+ Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" navigate"));
+ } else {
+ footer = Line.from(
+ Span.styled(" q", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" quit "),
+ Span.styled("/", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" search "),
+ Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" navigate "),
+ Span.styled("Tab", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" switch panel "),
+ Span.styled("Enter", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" select "),
+ Span.styled("PgUp/PgDn", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" scroll details"));
+ }
+
+ frame.renderWidget(Paragraph.from(footer), area);
+ }
+
+ // ---- Helpers ----
+
+ private static String truncate(String s, int max) {
+ if (s == null) {
+ return "";
+ }
+ return s.length() > max ? s.substring(0, max - 1) + "\u2026" : s;
+ }
+
+ // ---- Data Classes ----
+
+ static class ComponentInfo {
+ String name;
+ String title;
+ String description;
+ String label;
+ String groupId;
+ String artifactId;
+ String version;
+ String scheme;
+ String syntax;
+ boolean deprecated;
+ final List<OptionInfo> componentOptions = new ArrayList<>();
+ final List<OptionInfo> endpointOptions = new ArrayList<>();
+ }
+
+ static class OptionInfo {
+ String name;
+ String kind;
+ String type;
+ boolean required;
+ String defaultValue;
+ String description;
+ String group;
+ String enumValues;
+ }
+
+ static class ComponentDetail {
+ final ComponentInfo info;
+
+ ComponentDetail(ComponentInfo info) {
+ this.info = info;
+ }
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelHealthTui.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelHealthTui.java
new file mode 100644
index 000000000000..e0e1d46548ed
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelHealthTui.java
@@ -0,0 +1,498 @@
+/*
+ * 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.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+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.TuiRunner;
+import dev.tamboui.tui.event.Event;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.TickEvent;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.gauge.Gauge;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import dev.tamboui.widgets.table.Cell;
+import dev.tamboui.widgets.table.Row;
+import dev.tamboui.widgets.table.Table;
+import dev.tamboui.widgets.table.TableState;
+import org.apache.camel.dsl.jbang.core.commands.CamelCommand;
+import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.dsl.jbang.core.common.ProcessHelper;
+import org.apache.camel.support.PatternHelper;
+import org.apache.camel.util.FileUtil;
+import org.apache.camel.util.TimeUtils;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+
+@Command(name = "health-tui",
+ description = "TUI health check dashboard",
+ sortOptions = false)
+public class CamelHealthTui extends CamelCommand {
+
+ @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1")
+ String name = "*";
+
+ @CommandLine.Option(names = { "--refresh" },
+ description = "Refresh interval in milliseconds (default: ${DEFAULT-VALUE})",
+ defaultValue = "1000")
+ long refreshInterval = 1000;
+
+ // State
+ private final AtomicReference<List<HealthRow>> data = new AtomicReference<>(Collections.emptyList());
+ private final TableState tableState = new TableState();
+ private boolean showOnlyDown;
+ private volatile long lastRefresh;
+
+ // Memory info for the selected row's integration
+ private long selectedHeapUsed;
+ private long selectedHeapMax;
+
+ public CamelHealthTui(CamelJBangMain main) {
+ super(main);
+ }
+
+ @Override
+ public Integer doCall() throws Exception {
+ // Eagerly load classes used by the input reader thread and picocli
+ // post-processing to avoid ClassNotFoundException during shutdown
+ // when the Spring Boot LaunchedClassLoader may already be closing
+ try {
+ Class.forName("dev.tamboui.tui.event.KeyModifiers");
+ Class.forName("dev.tamboui.tui.event.KeyEvent");
+ Class.forName("dev.tamboui.tui.event.KeyCode");
+ Class.forName("picocli.CommandLine$IExitCodeGenerator");
+ } catch (ClassNotFoundException e) {
+ // ignore
+ }
+
+ // Initial data load
+ refreshData();
+
+ try (var tui = TuiRunner.create()) {
+ sun.misc.Signal.handle(new sun.misc.Signal("INT"), sig -> tui.quit());
+ tui.run(
+ this::handleEvent,
+ this::render);
+ }
+ return 0;
+ }
+
+ // ---- Event Handling ----
+
+ private boolean handleEvent(Event event, TuiRunner runner) {
+ if (event instanceof KeyEvent ke) {
+ if (ke.isQuit() || ke.isCharIgnoreCase('q') || ke.isKey(KeyCode.ESCAPE)) {
+ runner.quit();
+ return true;
+ }
+ if (ke.isChar('r')) {
+ refreshData();
+ return true;
+ }
+ if (ke.isChar('d')) {
+ showOnlyDown = !showOnlyDown;
+ return true;
+ }
+ if (ke.isUp()) {
+ tableState.selectPrevious();
+ return true;
+ }
+ if (ke.isDown()) {
+ List<HealthRow> rows = getFilteredRows();
+ tableState.selectNext(rows.size());
+ return true;
+ }
+ }
+ if (event instanceof TickEvent) {
+ long now = System.currentTimeMillis();
+ if (now - lastRefresh >= refreshInterval) {
+ refreshData();
+ }
+ return true;
+ }
+ return false;
+ }
+
+ // ---- Rendering ----
+
+ private void render(Frame frame) {
+ Rect area = frame.area();
+
+ // Layout: header (3 rows) + health table (fill) + memory gauge (3 rows) + footer (1 row)
+ List<Rect> chunks = Layout.vertical()
+ .constraints(
+ Constraint.length(3),
+ Constraint.fill(),
+ Constraint.length(3),
+ Constraint.length(1))
+ .split(area);
+
+ renderHeader(frame, chunks.get(0));
+ renderHealthTable(frame, chunks.get(1));
+ renderMemoryGauge(frame, chunks.get(2));
+ renderFooter(frame, chunks.get(3));
+ }
+
+ private void renderHeader(Frame frame, Rect area) {
+ List<HealthRow> allRows = data.get();
+ long upCount = allRows.stream().filter(r -> "UP".equals(r.state)).count();
+ long downCount = allRows.stream().filter(r -> "DOWN".equals(r.state)).count();
+
+ Line titleLine = Line.from(
+ Span.styled(" Health Dashboard", Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)).bold()),
+ Span.raw(" "),
+ Span.styled(upCount + " UP", Style.create().fg(Color.GREEN).bold()),
+ Span.raw(", "),
+ downCount > 0
+ ? Span.styled(downCount + " DOWN", Style.create().fg(Color.RED).bold())
+ : Span.styled("0 DOWN", Style.create().fg(Color.GREEN)),
+ showOnlyDown ? Span.styled(" [DOWN only]", Style.create().fg(Color.YELLOW)) : Span.raw(""));
+
+ Block headerBlock = Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(" Camel Health Checks ")
+ .build();
+
+ frame.renderWidget(
+ Paragraph.builder().text(Text.from(titleLine)).block(headerBlock).build(),
+ area);
+ }
+
+ private void renderHealthTable(Frame frame, Rect area) {
+ List<HealthRow> rows = getFilteredRows();
+
+ // Update selected integration's memory info
+ Integer sel = tableState.selected();
+ if (sel != null && sel >= 0 && sel < rows.size()) {
+ HealthRow selected = rows.get(sel);
+ selectedHeapUsed = selected.heapMemUsed;
+ selectedHeapMax = selected.heapMemMax;
+ } else if (!rows.isEmpty()) {
+ selectedHeapUsed = rows.get(0).heapMemUsed;
+ selectedHeapMax = rows.get(0).heapMemMax;
+ } else {
+ selectedHeapUsed = 0;
+ selectedHeapMax = 0;
+ }
+
+ List<Row> tableRows = new ArrayList<>();
+ for (HealthRow hr : rows) {
+ Style stateStyle;
+ String icon;
+ if ("UP".equals(hr.state)) {
+ stateStyle = Style.create().fg(Color.GREEN);
+ icon = "\u2714 ";
+ } else if ("DOWN".equals(hr.state)) {
+ stateStyle = Style.create().fg(Color.RED);
+ icon = "\u2716 ";
+ } else {
+ stateStyle = Style.create().fg(Color.YELLOW);
+ icon = "\u26A0 ";
+ }
+
+ String rate = "";
+ if (hr.readiness) {
+ rate += "R";
+ }
+ if (hr.liveness) {
+ rate += rate.isEmpty() ? "L" : "/L";
+ }
+
+ tableRows.add(Row.from(
+ Cell.from(hr.pid),
+ Cell.from(Span.styled(truncate(hr.integrationName, 20), Style.create().fg(Color.CYAN))),
+ Cell.from(hr.group != null ? truncate(hr.group, 12) : ""),
+ Cell.from(Span.styled(truncate(hr.checkId, 25), Style.create().fg(Color.CYAN))),
+ Cell.from(Span.styled(icon + hr.state, stateStyle)),
+ Cell.from(rate),
+ Cell.from(hr.since != null ? hr.since : ""),
+ Cell.from(hr.message != null ? truncate(hr.message, 40) : "")));
+ }
+
+ if (tableRows.isEmpty()) {
+ tableRows.add(Row.from(
+ Cell.from(""),
+ Cell.from(Span.styled("No health checks found", Style.create().dim())),
+ Cell.from(""),
+ Cell.from(""),
+ Cell.from(""),
+ Cell.from(""),
+ Cell.from(""),
+ Cell.from("")));
+ }
+
+ Row header = Row.from(
+ Cell.from(Span.styled("PID", Style.create().bold())),
+ Cell.from(Span.styled("NAME", Style.create().bold())),
+ Cell.from(Span.styled("GROUP", Style.create().bold())),
+ Cell.from(Span.styled("CHECK", Style.create().bold())),
+ Cell.from(Span.styled("STATUS", Style.create().bold())),
+ Cell.from(Span.styled("RATE", Style.create().bold())),
+ Cell.from(Span.styled("SINCE", Style.create().bold())),
+ Cell.from(Span.styled("MESSAGE", Style.create().bold())));
+
+ Table table = Table.builder()
+ .rows(tableRows)
+ .header(header)
+ .widths(
+ Constraint.length(8),
+ Constraint.length(20),
+ Constraint.length(12),
+ Constraint.length(25),
+ Constraint.length(10),
+ Constraint.length(6),
+ Constraint.length(8),
+ Constraint.fill())
+ .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue())
+ .block(Block.builder().borderType(BorderType.ROUNDED)
+ .title(showOnlyDown ? " Health Checks [DOWN only] " : " Health Checks ").build())
+ .build();
+
+ frame.renderStatefulWidget(table, area, tableState);
+ }
+
+ private void renderMemoryGauge(Frame frame, Rect area) {
+ if (selectedHeapMax > 0) {
+ int pct = (int) (100.0 * selectedHeapUsed / selectedHeapMax);
+ Style gaugeStyle = pct > 80 ? Style.create().fg(Color.RED)
+ : pct > 60 ? Style.create().fg(Color.YELLOW) : Style.create().fg(Color.GREEN);
+ Gauge gauge = Gauge.builder()
+ .percent(pct)
+ .label(String.format("Heap: %s / %s (%d%%)",
+ formatBytes(selectedHeapUsed), formatBytes(selectedHeapMax), pct))
+ .gaugeStyle(gaugeStyle)
+ .block(Block.builder().borderType(BorderType.ROUNDED).title(" Memory ").build())
+ .build();
+ frame.renderWidget(gauge, area);
+ } else {
+ frame.renderWidget(
+ Paragraph.builder()
+ .text(Text.from(Line.from(Span.styled(" No memory data", Style.create().dim()))))
+ .block(Block.builder().borderType(BorderType.ROUNDED).title(" Memory ").build())
+ .build(),
+ area);
+ }
+ }
+
+ private void renderFooter(Frame frame, Rect area) {
+ String refreshLabel = refreshInterval >= 1000
+ ? (refreshInterval / 1000) + "s"
+ : refreshInterval + "ms";
+
+ Line footer = Line.from(
+ Span.styled(" q", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw("/"),
+ Span.styled("Esc", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" quit "),
+ Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" navigate "),
+ Span.styled("r", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" refresh "),
+ Span.styled("d", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" toggle DOWN "),
+ Span.styled("Refresh: " + refreshLabel, Style.create().dim()));
+
+ frame.renderWidget(Paragraph.from(footer), area);
+ }
+
+ // ---- Data Loading ----
+
+ private void refreshData() {
+ lastRefresh = System.currentTimeMillis();
+ try {
+ List<HealthRow> rows = new ArrayList<>();
+ List<Long> pids = findPids(name);
+ ProcessHandle.allProcesses()
+ .filter(ph -> pids.contains(ph.pid()))
+ .forEach(ph -> {
+ JsonObject root = loadStatus(ph.pid());
+ if (root != null) {
+ parseHealthRows(ph, root, rows);
+ }
+ });
+ data.set(rows);
+ } catch (Exception e) {
+ // ignore refresh errors
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private void parseHealthRows(ProcessHandle ph, JsonObject root, List<HealthRow> rows) {
+ JsonObject context = (JsonObject) root.get("context");
+ if (context == null) {
+ return;
+ }
+
+ String pid = Long.toString(ph.pid());
+ String integrationName = context.getString("name");
+ if ("CamelJBang".equals(integrationName)) {
+ integrationName = ProcessHelper.extractName(root, ph);
+ }
+
+ String since = TimeUtils.printSince(
+ ph.info().startInstant().map(Instant::toEpochMilli).orElse(0L));
+
+ // Parse memory
+ long heapUsed = 0;
+ long heapMax = 0;
+ JsonObject mem = (JsonObject) root.get("memory");
+ if (mem != null) {
+ heapUsed = mem.getLong("heapMemoryUsed");
+ heapMax = mem.getLong("heapMemoryMax");
+ }
+
+ // Parse health checks
+ JsonObject healthChecks = (JsonObject) root.get("healthChecks");
+ if (healthChecks != null) {
+ JsonArray checks = (JsonArray) healthChecks.get("checks");
+ if (checks != null) {
+ for (Object c : checks) {
+ JsonObject cj = (JsonObject) c;
+ HealthRow hr = new HealthRow();
+ hr.pid = pid;
+ hr.integrationName = integrationName;
+ hr.checkId = cj.getString("id");
+ hr.group = cj.getString("group");
+ hr.state = cj.getString("state");
+ hr.readiness = cj.getBooleanOrDefault("readiness", false);
+ hr.liveness = cj.getBooleanOrDefault("liveness", false);
+ hr.since = since;
+ hr.heapMemUsed = heapUsed;
+ hr.heapMemMax = heapMax;
+
+ // Extract failure message from details
+ JsonObject details = (JsonObject) cj.get("details");
+ if (details != null && details.containsKey("failure.error.message")) {
+ hr.message = details.getString("failure.error.message");
+ }
+
+ rows.add(hr);
+ }
+ }
+ }
+ }
+
+ // ---- Helpers ----
+
+ private List<HealthRow> getFilteredRows() {
+ List<HealthRow> allRows = data.get();
+ if (showOnlyDown) {
+ return allRows.stream().filter(r -> "DOWN".equals(r.state)).toList();
+ }
+ return allRows;
+ }
+
+ private List<Long> findPids(String name) {
+ List<Long> pids = new ArrayList<>();
+ final long cur = ProcessHandle.current().pid();
+ String pattern = name;
+ if (!pattern.matches("\\d+") && !pattern.endsWith("*")) {
+ pattern = pattern + "*";
+ }
+ final String pat = pattern;
+ ProcessHandle.allProcesses()
+ .filter(ph -> ph.pid() != cur)
+ .forEach(ph -> {
+ JsonObject root = loadStatus(ph.pid());
+ if (root != null) {
+ String pName = ProcessHelper.extractName(root, ph);
+ pName = FileUtil.onlyName(pName);
+ if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) {
+ pids.add(ph.pid());
+ } else {
+ JsonObject context = (JsonObject) root.get("context");
+ if (context != null) {
+ pName = context.getString("name");
+ if ("CamelJBang".equals(pName)) {
+ pName = null;
+ }
+ if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) {
+ pids.add(ph.pid());
+ }
+ }
+ }
+ }
+ });
+ return pids;
+ }
+
+ private JsonObject loadStatus(long pid) {
+ try {
+ Path f = getStatusFile(Long.toString(pid));
+ if (f != null && Files.exists(f)) {
+ String text = Files.readString(f);
+ return (JsonObject) Jsoner.deserialize(text);
+ }
+ } catch (Exception e) {
+ // ignore
+ }
+ return null;
+ }
+
+ private static String truncate(String s, int max) {
+ if (s == null) {
+ return "";
+ }
+ return s.length() > max ? s.substring(0, max - 1) + "\u2026" : s;
+ }
+
+ private static String formatBytes(long bytes) {
+ if (bytes < 1024) {
+ return bytes + "B";
+ }
+ if (bytes < 1024 * 1024) {
+ return (bytes / 1024) + "K";
+ }
+ return (bytes / (1024 * 1024)) + "M";
+ }
+
+ // ---- Data Class ----
+
+ static class HealthRow {
+ String pid;
+ String integrationName;
+ String group;
+ String checkId;
+ String state;
+ boolean readiness;
+ boolean liveness;
+ String since;
+ String message;
+ long heapMemUsed;
+ long heapMemMax;
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelLogTui.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelLogTui.java
new file mode 100644
index 000000000000..1bede16a8396
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelLogTui.java
@@ -0,0 +1,498 @@
+/*
+ * 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.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+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.Overflow;
+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.TuiRunner;
+import dev.tamboui.tui.event.Event;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.TickEvent;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import org.apache.camel.dsl.jbang.core.commands.CamelCommand;
+import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.apache.camel.dsl.jbang.core.common.ProcessHelper;
+import org.apache.camel.support.PatternHelper;
+import org.apache.camel.util.FileUtil;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+
+@Command(name = "log-tui",
+ description = "TUI log viewer for Camel integrations",
+ sortOptions = false)
+public class CamelLogTui extends CamelCommand {
+
+ private static final int MAX_READ_BYTES = 64 * 1024;
+
+ @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1")
+ String name = "*";
+
+ @CommandLine.Option(names = { "--refresh" },
+ description = "Refresh interval in milliseconds (default: ${DEFAULT-VALUE})",
+ defaultValue = "500")
+ long refreshInterval = 500;
+
+ @CommandLine.Option(names = { "--grep" },
+ description = "Filter logs to matching lines")
+ String grep;
+
+ @CommandLine.Option(names = { "--level" },
+ description = "Filter by log level (INFO, WARN, ERROR, DEBUG)")
+ String level;
+
+ // State
+ private final List<String> logLines = new ArrayList<>();
+ private final List<String> filteredLines = new ArrayList<>();
+ private int scrollOffset;
+ private boolean followMode = true;
+
+ // Level toggle filters (all enabled by default)
+ private boolean showTrace = true;
+ private boolean showDebug = true;
+ private boolean showInfo = true;
+ private boolean showWarn = true;
+ private boolean showError = true;
+
+ // Integration info
+ private String resolvedPid;
+ private String integrationName;
+ private Path logFilePath;
+
+ private volatile long lastRefresh;
+
+ public CamelLogTui(CamelJBangMain main) {
+ super(main);
+ }
+
+ @Override
+ public Integer doCall() throws Exception {
+ // Eagerly load classes used by the input reader thread and picocli
+ // post-processing to avoid ClassNotFoundException during shutdown
+ try {
+ Class.forName("dev.tamboui.tui.event.KeyModifiers");
+ Class.forName("dev.tamboui.tui.event.KeyEvent");
+ Class.forName("dev.tamboui.tui.event.KeyCode");
+ Class.forName("picocli.CommandLine$IExitCodeGenerator");
+ } catch (ClassNotFoundException e) {
+ // ignore
+ }
+
+ // Apply --level as initial level filter
+ if (level != null) {
+ String lv = level.toUpperCase();
+ showTrace = "TRACE".equals(lv);
+ showDebug = "DEBUG".equals(lv) || "TRACE".equals(lv);
+ showInfo = "INFO".equals(lv) || "DEBUG".equals(lv) || "TRACE".equals(lv);
+ showWarn = "WARN".equals(lv) || "INFO".equals(lv) || "DEBUG".equals(lv) || "TRACE".equals(lv);
+ showError = true; // always show errors
+ }
+
+ // Initial data load
+ resolveIntegration();
+ refreshLogData();
+
+ try (var tui = TuiRunner.create()) {
+ sun.misc.Signal.handle(new sun.misc.Signal("INT"), sig -> tui.quit());
+ tui.run(
+ this::handleEvent,
+ this::render);
+ }
+ return 0;
+ }
+
+ // ---- Event Handling ----
+
+ private boolean handleEvent(Event event, TuiRunner runner) {
+ if (event instanceof KeyEvent ke) {
+ if (ke.isQuit() || ke.isCharIgnoreCase('q') || ke.isKey(KeyCode.ESCAPE)) {
+ runner.quit();
+ return true;
+ }
+ if (ke.isUp()) {
+ followMode = false;
+ scrollOffset = Math.max(0, scrollOffset - 1);
+ return true;
+ }
+ if (ke.isDown()) {
+ scrollOffset++;
+ return true;
+ }
+ if (ke.isKey(KeyCode.PAGE_UP)) {
+ followMode = false;
+ scrollOffset = Math.max(0, scrollOffset - 20);
+ return true;
+ }
+ if (ke.isKey(KeyCode.PAGE_DOWN)) {
+ scrollOffset += 20;
+ return true;
+ }
+ if (ke.isChar('g')) {
+ followMode = false;
+ scrollOffset = 0;
+ return true;
+ }
+ if (ke.isChar('G')) {
+ followMode = true;
+ return true;
+ }
+ if (ke.isChar('f')) {
+ followMode = !followMode;
+ return true;
+ }
+ // Level toggles: 1=TRACE, 2=DEBUG, 3=INFO, 4=WARN, 5=ERROR
+ if (ke.isChar('1')) {
+ showTrace = !showTrace;
+ applyFilters();
+ return true;
+ }
+ if (ke.isChar('2')) {
+ showDebug = !showDebug;
+ applyFilters();
+ return true;
+ }
+ if (ke.isChar('3')) {
+ showInfo = !showInfo;
+ applyFilters();
+ return true;
+ }
+ if (ke.isChar('4')) {
+ showWarn = !showWarn;
+ applyFilters();
+ return true;
+ }
+ if (ke.isChar('5')) {
+ showError = !showError;
+ applyFilters();
+ return true;
+ }
+ }
+ if (event instanceof TickEvent) {
+ long now = System.currentTimeMillis();
+ if (now - lastRefresh >= refreshInterval) {
+ resolveIntegration();
+ refreshLogData();
+ }
+ return true;
+ }
+ return false;
+ }
+
+ // ---- Rendering ----
+
+ private void render(Frame frame) {
+ Rect area = frame.area();
+
+ // Layout: header (3 rows) + log area (fill) + footer (1 row)
+ List<Rect> mainChunks = Layout.vertical()
+ .constraints(
+ Constraint.length(3),
+ Constraint.fill(),
+ Constraint.length(1))
+ .split(area);
+
+ renderHeader(frame, mainChunks.get(0));
+ renderLogArea(frame, mainChunks.get(1));
+ renderFooter(frame, mainChunks.get(2));
+ }
+
+ private void renderHeader(Frame frame, Rect area) {
+ String titleText = integrationName != null ? integrationName : name;
+ String logInfo = logFilePath != null ? logFilePath.getFileName().toString() : "no log file";
+ String lineCount = filteredLines.size() + " lines";
+
+ Line titleLine = Line.from(
+ Span.styled(" Log Viewer", Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)).bold()),
+ Span.raw(" "),
+ Span.styled(titleText, Style.create().fg(Color.CYAN)),
+ Span.raw(" "),
+ Span.styled(logInfo, Style.create().dim()),
+ Span.raw(" "),
+ Span.styled(lineCount, Style.create().fg(Color.GREEN)),
+ followMode
+ ? Span.styled(" [FOLLOW]", Style.create().fg(Color.YELLOW).bold())
+ : Span.raw(""));
+
+ Block headerBlock = Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(" Camel Log ")
+ .build();
+
+ frame.renderWidget(
+ Paragraph.builder().text(Text.from(titleLine)).block(headerBlock).build(),
+ area);
+ }
+
+ private void renderLogArea(Frame frame, Rect area) {
+ Block logBlock = Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(buildLevelFilterTitle())
+ .build();
+
+ int innerHeight = Math.max(1, area.height() - 2);
+ int totalLines = filteredLines.size();
+
+ int startLine;
+ if (followMode) {
+ startLine = Math.max(0, totalLines - innerHeight);
+ scrollOffset = startLine;
+ } else {
+ scrollOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, totalLines - innerHeight)));
+ startLine = scrollOffset;
+ }
+
+ List<Line> visibleLines = new ArrayList<>();
+ for (int i = startLine; i < Math.min(startLine + innerHeight, totalLines); i++) {
+ visibleLines.add(colorizeLogLine(filteredLines.get(i)));
+ }
+
+ // Fill remaining space
+ while (visibleLines.size() < innerHeight) {
+ visibleLines.add(Line.from(Span.raw("")));
+ }
+
+ Paragraph logParagraph = Paragraph.builder()
+ .text(Text.from(visibleLines))
+ .overflow(Overflow.CLIP)
+ .block(logBlock)
+ .build();
+
+ frame.renderWidget(logParagraph, area);
+ }
+
+ private String buildLevelFilterTitle() {
+ StringBuilder sb = new StringBuilder(" Levels: ");
+ sb.append(showTrace ? "[1:TRACE] " : "[1:trace] ");
+ sb.append(showDebug ? "[2:DEBUG] " : "[2:debug] ");
+ sb.append(showInfo ? "[3:INFO] " : "[3:info] ");
+ sb.append(showWarn ? "[4:WARN] " : "[4:warn] ");
+ sb.append(showError ? "[5:ERROR] " : "[5:error] ");
+ return sb.toString();
+ }
+
+ private void renderFooter(Frame frame, Rect area) {
+ Line footer = Line.from(
+ Span.styled(" q", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" quit "),
+ Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" scroll "),
+ Span.styled("g", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw("/"),
+ Span.styled("G", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" top/bottom "),
+ Span.styled("f", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" follow "),
+ Span.styled("PgUp/PgDn", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" page "),
+ Span.styled("1-5", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" toggle levels"),
+ grep != null
+ ? Span.styled(" grep:" + grep, Style.create().fg(Color.MAGENTA))
+ : Span.raw(""));
+
+ frame.renderWidget(Paragraph.from(footer), area);
+ }
+
+ private Line colorizeLogLine(String line) {
+ if (line.contains(" ERROR ") || line.contains(" FATAL ")) {
+ return Line.from(Span.styled(line, Style.create().fg(Color.RED)));
+ } else if (line.contains(" WARN ")) {
+ return Line.from(Span.styled(line, Style.create().fg(Color.YELLOW)));
+ } else if (line.contains(" DEBUG ")) {
+ return Line.from(Span.styled(line, Style.create().dim()));
+ } else if (line.contains(" TRACE ")) {
+ return Line.from(Span.styled(line, Style.create().dim()));
+ }
+ return Line.from(Span.raw(line));
+ }
+
+ // ---- Data Loading ----
+
+ private void resolveIntegration() {
+ if (resolvedPid != null) {
+ // Check if still alive
+ try {
+ ProcessHandle.of(Long.parseLong(resolvedPid)).ifPresentOrElse(
+ ph -> {
+ if (!ph.isAlive()) {
+ resolvedPid = null;
+ integrationName = null;
+ logFilePath = null;
+ }
+ },
+ () -> {
+ resolvedPid = null;
+ integrationName = null;
+ logFilePath = null;
+ });
+ } catch (NumberFormatException e) {
+ // ignore
+ }
+ }
+
+ if (resolvedPid == null) {
+ List<Long> pids = findPids(name);
+ if (!pids.isEmpty()) {
+ long pid = pids.get(0);
+ resolvedPid = Long.toString(pid);
+ logFilePath = CommandLineHelper.getCamelDir().resolve(resolvedPid + ".log");
+
+ // Load integration name
+ JsonObject root = loadStatus(pid);
+ if (root != null) {
+ JsonObject context = (JsonObject) root.get("context");
+ if (context != null) {
+ integrationName = context.getString("name");
+ if ("CamelJBang".equals(integrationName)) {
+ ProcessHandle.of(pid).ifPresent(
+ ph -> integrationName = ProcessHelper.extractName(root, ph));
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private void refreshLogData() {
+ lastRefresh = System.currentTimeMillis();
+ readLogFile();
+ applyFilters();
+ }
+
+ private void readLogFile() {
+ logLines.clear();
+ if (logFilePath == null || !Files.exists(logFilePath)) {
+ return;
+ }
+ try (RandomAccessFile raf = new RandomAccessFile(logFilePath.toFile(), "r")) {
+ long length = raf.length();
+ long startPos = Math.max(0, length - MAX_READ_BYTES);
+ raf.seek(startPos);
+ if (startPos > 0) {
+ raf.readLine(); // skip partial line
+ }
+ byte[] remaining = new byte[(int) (length - raf.getFilePointer())];
+ raf.readFully(remaining);
+ String content = new String(remaining, StandardCharsets.UTF_8);
+ String[] lines = content.split("\n", -1);
+ for (String line : lines) {
+ if (!line.isEmpty()) {
+ logLines.add(line);
+ }
+ }
+ } catch (IOException e) {
+ // ignore
+ }
+ }
+
+ private void applyFilters() {
+ filteredLines.clear();
+ for (String line : logLines) {
+ if (!matchesLevelFilter(line)) {
+ continue;
+ }
+ if (grep != null && !grep.isEmpty() && !line.contains(grep)) {
+ continue;
+ }
+ filteredLines.add(line);
+ }
+ }
+
+ private boolean matchesLevelFilter(String line) {
+ if (line.contains(" ERROR ") || line.contains(" FATAL ")) {
+ return showError;
+ } else if (line.contains(" WARN ")) {
+ return showWarn;
+ } else if (line.contains(" DEBUG ")) {
+ return showDebug;
+ } else if (line.contains(" TRACE ")) {
+ return showTrace;
+ }
+ // Lines without a recognized level are treated as INFO
+ return showInfo;
+ }
+
+ // ---- Helpers ----
+
+ private List<Long> findPids(String name) {
+ List<Long> pids = new ArrayList<>();
+ final long cur = ProcessHandle.current().pid();
+ String pattern = name;
+ if (!pattern.matches("\\d+") && !pattern.endsWith("*")) {
+ pattern = pattern + "*";
+ }
+ final String pat = pattern;
+ ProcessHandle.allProcesses()
+ .filter(ph -> ph.pid() != cur)
+ .forEach(ph -> {
+ JsonObject root = loadStatus(ph.pid());
+ if (root != null) {
+ String pName = ProcessHelper.extractName(root, ph);
+ pName = FileUtil.onlyName(pName);
+ if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) {
+ pids.add(ph.pid());
+ } else {
+ JsonObject context = (JsonObject) root.get("context");
+ if (context != null) {
+ pName = context.getString("name");
+ if ("CamelJBang".equals(pName)) {
+ pName = null;
+ }
+ if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) {
+ pids.add(ph.pid());
+ }
+ }
+ }
+ }
+ });
+ return pids;
+ }
+
+ private JsonObject loadStatus(long pid) {
+ try {
+ Path f = getStatusFile(Long.toString(pid));
+ if (f != null && Files.exists(f)) {
+ String text = Files.readString(f);
+ return (JsonObject) Jsoner.deserialize(text);
+ }
+ } catch (Exception e) {
+ // ignore
+ }
+ return null;
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
new file mode 100644
index 000000000000..df8d3dc0dfae
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -0,0 +1,1301 @@
+/*
+ * 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.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicReference;
+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.Overflow;
+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.TuiRunner;
+import dev.tamboui.tui.event.Event;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.TickEvent;
+import dev.tamboui.widgets.barchart.Bar;
+import dev.tamboui.widgets.barchart.BarChart;
+import dev.tamboui.widgets.barchart.BarGroup;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.gauge.Gauge;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import dev.tamboui.widgets.table.Cell;
+import dev.tamboui.widgets.table.Row;
+import dev.tamboui.widgets.table.Table;
+import dev.tamboui.widgets.table.TableState;
+import dev.tamboui.widgets.tabs.Tabs;
+import dev.tamboui.widgets.tabs.TabsState;
+import org.apache.camel.dsl.jbang.core.commands.CamelCommand;
+import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.apache.camel.dsl.jbang.core.common.ProcessHelper;
+import org.apache.camel.dsl.jbang.core.common.VersionHelper;
+import org.apache.camel.support.PatternHelper;
+import org.apache.camel.util.FileUtil;
+import org.apache.camel.util.TimeUtils;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+
+import static org.apache.camel.dsl.jbang.core.common.CamelCommandHelper.extractState;
+
+@Command(name = "monitor",
+ description = "Live TUI dashboard for monitoring Camel integrations",
+ sortOptions = false)
+public class CamelMonitor extends CamelCommand {
+
+ private static final long VANISH_DURATION_MS = 6000;
+ private static final long DEFAULT_REFRESH_MS = 100;
+ private static final int MAX_SPARKLINE_POINTS = 60;
+ private static final int MAX_LOG_LINES = 200;
+
+ // Tab indices
+ private static final int TAB_OVERVIEW = 0;
+ private static final int TAB_ROUTES = 1;
+ private static final int TAB_HEALTH = 2;
+ private static final int TAB_ENDPOINTS = 3;
+ private static final int TAB_LOG = 4;
+
+ @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1")
+ String name = "*";
+
+ @CommandLine.Option(names = { "--refresh" },
+ description = "Refresh interval in milliseconds (default: ${DEFAULT-VALUE})",
+ defaultValue = "100")
+ long refreshInterval = DEFAULT_REFRESH_MS;
+
+ // State
+ private final AtomicReference<List<IntegrationInfo>> data = new AtomicReference<>(Collections.emptyList());
+ private final Map<String, VanishingInfo> vanishing = new ConcurrentHashMap<>();
+ private final TableState overviewTableState = new TableState();
+ private final TableState routeTableState = new TableState();
+ private final TableState healthTableState = new TableState();
+ private final TableState endpointTableState = new TableState();
+ private final TabsState tabsState = new TabsState(TAB_OVERVIEW);
+
+ // Sparkline: throughput history per PID (one point per second)
+ private final Map<String, LinkedList<Long>> throughputHistory = new ConcurrentHashMap<>();
+ // Sliding window of [timestamp, exchangesTotal] samples for smoothing
+ private final Map<String, LinkedList<long[]>> throughputSamples = new ConcurrentHashMap<>();
+ // Track last time a sparkline point was recorded
+ private final Map<String, Long> previousExchangesTime = new ConcurrentHashMap<>();
+
+ // Log state
+ private final List<String> logLines = new ArrayList<>();
+ private int logScroll;
+
+ // Selected integration for detail views
+ private String selectedPid;
+
+ private volatile long lastRefresh;
+
+ public CamelMonitor(CamelJBangMain main) {
+ super(main);
+ }
+
+ @Override
+ public Integer doCall() throws Exception {
+ // Eagerly load classes used by the input reader thread and picocli
+ // post-processing to avoid ClassNotFoundException during shutdown
+ // when the Spring Boot LaunchedClassLoader may already be closing
+ try {
+ Class.forName("dev.tamboui.tui.event.KeyModifiers");
+ Class.forName("dev.tamboui.tui.event.KeyEvent");
+ Class.forName("dev.tamboui.tui.event.KeyCode");
+ Class.forName("picocli.CommandLine$IExitCodeGenerator");
+ } catch (ClassNotFoundException e) {
+ // ignore
+ }
+
+ // Initial data load
+ refreshData();
+
+ try (var tui = TuiRunner.create()) {
+ // Intercept Ctrl+C: quit the TUI cleanly instead of letting
+ // the JVM tear down the classloader while we're still running
+ sun.misc.Signal.handle(new sun.misc.Signal("INT"), sig -> tui.quit());
+ tui.run(
+ this::handleEvent,
+ this::render);
+ }
+ return 0;
+ }
+
+ // ---- Event Handling ----
+
+ private boolean handleEvent(Event event, TuiRunner runner) {
+ if (event instanceof KeyEvent ke) {
+ // Global keys
+ if (ke.isQuit() || ke.isCharIgnoreCase('q') || ke.isKey(KeyCode.ESCAPE)) {
+ // If in a detail tab, go back to overview first
+ if (tabsState.selected() != TAB_OVERVIEW) {
+ tabsState.select(TAB_OVERVIEW);
+ selectedPid = null;
+ return true;
+ }
+ runner.quit();
+ return true;
+ }
+ if (ke.isChar('r')) {
+ refreshData();
+ return true;
+ }
+
+ // Tab switching with number keys
+ if (ke.isChar('1')) {
+ tabsState.select(TAB_OVERVIEW);
+ selectedPid = null;
+ return true;
+ }
+ if (ke.isChar('2')) {
+ selectCurrentIntegration();
+ tabsState.select(TAB_ROUTES);
+ return true;
+ }
+ if (ke.isChar('3')) {
+ selectCurrentIntegration();
+ tabsState.select(TAB_HEALTH);
+ return true;
+ }
+ if (ke.isChar('4')) {
+ selectCurrentIntegration();
+ tabsState.select(TAB_ENDPOINTS);
+ return true;
+ }
+ if (ke.isChar('5')) {
+ selectCurrentIntegration();
+ tabsState.select(TAB_LOG);
+ logScroll = 0;
+ return true;
+ }
+
+ // Tab cycling
+ if (ke.isKey(KeyCode.TAB)) {
+ int next = (tabsState.selected() + 1) % 5;
+ if (next != TAB_OVERVIEW) {
+ selectCurrentIntegration();
+ }
+ tabsState.select(next);
+ if (next == TAB_LOG) {
+ logScroll = 0;
+ }
+ return true;
+ }
+
+ // Navigation
+ if (ke.isUp()) {
+ navigateUp();
+ return true;
+ }
+ if (ke.isDown()) {
+ navigateDown();
+ return true;
+ }
+ if (ke.isKey(KeyCode.PAGE_UP)) {
+ if (tabsState.selected() == TAB_LOG) {
+ logScroll = Math.max(0, logScroll - 20);
+ }
+ return true;
+ }
+ if (ke.isKey(KeyCode.PAGE_DOWN)) {
+ if (tabsState.selected() == TAB_LOG) {
+ logScroll += 20;
+ }
+ return true;
+ }
+
+ // Enter to drill into selected integration
+ if (ke.isKey(KeyCode.ENTER) && tabsState.selected() == TAB_OVERVIEW) {
+ selectCurrentIntegration();
+ if (selectedPid != null) {
+ tabsState.select(TAB_ROUTES);
+ }
+ return true;
+ }
+ }
+ if (event instanceof TickEvent) {
+ long now = System.currentTimeMillis();
+ if (now - lastRefresh >= refreshInterval) {
+ refreshData();
+ }
+ return true;
+ }
+ return false;
+ }
+
+ private void selectCurrentIntegration() {
+ if (selectedPid != null) {
+ return;
+ }
+ List<IntegrationInfo> infos = data.get().stream().filter(i -> !i.vanishing).toList();
+ Integer sel = overviewTableState.selected();
+ if (sel != null && sel >= 0 && sel < infos.size()) {
+ selectedPid = infos.get(sel).pid;
+ } else if (infos.size() == 1) {
+ selectedPid = infos.get(0).pid;
+ }
+ }
+
+ private void navigateUp() {
+ switch (tabsState.selected()) {
+ case TAB_OVERVIEW -> overviewTableState.selectPrevious();
+ case TAB_ROUTES -> routeTableState.selectPrevious();
+ case TAB_HEALTH -> healthTableState.selectPrevious();
+ case TAB_ENDPOINTS -> endpointTableState.selectPrevious();
+ case TAB_LOG -> logScroll = Math.max(0, logScroll - 1);
+ }
+ }
+
+ private void navigateDown() {
+ List<IntegrationInfo> infos = data.get().stream().filter(i -> !i.vanishing).toList();
+ switch (tabsState.selected()) {
+ case TAB_OVERVIEW -> overviewTableState.selectNext(infos.size());
+ case TAB_ROUTES -> {
+ IntegrationInfo info = findSelectedIntegration();
+ routeTableState.selectNext(info != null ? info.routes.size() : 0);
+ }
+ case TAB_HEALTH -> {
+ IntegrationInfo info = findSelectedIntegration();
+ healthTableState.selectNext(info != null ? info.healthChecks.size() : 0);
+ }
+ case TAB_ENDPOINTS -> {
+ IntegrationInfo info = findSelectedIntegration();
+ endpointTableState.selectNext(info != null ? info.endpoints.size() : 0);
+ }
+ case TAB_LOG -> logScroll++;
+ }
+ }
+
+ // ---- Rendering ----
+
+ private void render(Frame frame) {
+ Rect area = frame.area();
+
+ // Layout: header (3 rows) + tabs (2 rows) + content (fill) + footer (1 row)
+ List<Rect> mainChunks = Layout.vertical()
+ .constraints(
+ Constraint.length(3),
+ Constraint.length(2),
+ Constraint.fill(),
+ Constraint.length(1))
+ .split(area);
+
+ renderHeader(frame, mainChunks.get(0));
+ renderTabs(frame, mainChunks.get(1));
+ renderContent(frame, mainChunks.get(2));
+ renderFooter(frame, mainChunks.get(3));
+ }
+
+ private void renderHeader(Frame frame, Rect area) {
+ List<IntegrationInfo> infos = data.get();
+ String camelVersion = VersionHelper.extractCamelVersion();
+ long activeCount = infos.stream().filter(i -> !i.vanishing).count();
+
+ Line titleLine = Line.from(
+ Span.styled(" Camel Monitor", Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)).bold()),
+ Span.raw(" "),
+ Span.styled(camelVersion != null ? "v" + camelVersion : "", Style.create().fg(Color.GREEN)),
+ Span.raw(" "),
+ Span.styled(activeCount + " integration(s)", Style.create().fg(Color.CYAN)));
+
+ Block headerBlock = Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(" Apache Camel ")
+ .build();
+
+ frame.renderWidget(
+ Paragraph.builder().text(Text.from(titleLine)).block(headerBlock).build(),
+ area);
+ }
+
+ private void renderTabs(Frame frame, Rect area) {
+ String sel = selectedPid != null ? " [" + selectedName() + "]" : "";
+ Tabs tabs = Tabs.builder()
+ .titles(
+ " 1 Overview ",
+ " 2 Routes" + sel + " ",
+ " 3 Health" + sel + " ",
+ " 4 Endpoints" + sel + " ",
+ " 5 Log" + sel + " ")
+ .highlightStyle(Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)).bold())
+ .divider(Span.styled(" | ", Style.create().dim()))
+ .build();
+
+ frame.renderStatefulWidget(tabs, area, tabsState);
+ }
+
+ private void renderContent(Frame frame, Rect area) {
+ // Clear the content area to prevent artifacts when switching tabs
+ frame.buffer().clear(area);
+ switch (tabsState.selected()) {
+ case TAB_OVERVIEW -> renderOverview(frame, area);
+ case TAB_ROUTES -> renderRoutes(frame, area);
+ case TAB_HEALTH -> renderHealth(frame, area);
+ case TAB_ENDPOINTS -> renderEndpoints(frame, area);
+ case TAB_LOG -> renderLog(frame, area);
+ }
+ }
+
+ // ---- Tab 1: Overview ----
+
+ private void renderOverview(Frame frame, Rect area) {
+ List<IntegrationInfo> infos = data.get();
+
+ // Split: table (fill) + sparkline (height 8) if we have data
+ boolean hasSparkline = !throughputHistory.isEmpty();
+ List<Rect> chunks;
+ if (hasSparkline) {
+ chunks = Layout.vertical()
+ .constraints(Constraint.fill(), Constraint.length(8))
+ .split(area);
+ } else {
+ chunks = List.of(area);
+ }
+
+ // Integration table
+ List<Row> rows = new ArrayList<>();
+ for (IntegrationInfo info : infos) {
+ if (info.vanishing) {
+ long elapsed = System.currentTimeMillis() - info.vanishStart;
+ float fade = 1.0f - Math.min(1.0f, (float) elapsed / VANISH_DURATION_MS);
+ int gray = (int) (100 * fade);
+ Style dimStyle = Style.create().fg(Color.indexed(232 + Math.min(gray / 4, 23)));
+
+ rows.add(Row.from(
+ Cell.from(Span.styled(info.pid, dimStyle)),
+ Cell.from(Span.styled(truncate(info.name, 25), dimStyle)),
+ Cell.from(Span.styled(info.platform != null ? info.platform : "", dimStyle)),
+ Cell.from(Span.styled("\u2716 Stopped", Style.create().fg(Color.RED).dim())),
+ Cell.from(Span.styled(info.ago != null ? info.ago : "", dimStyle)),
+ Cell.from(Span.styled("", dimStyle)),
+ Cell.from(Span.styled("", dimStyle)),
+ Cell.from(Span.styled("", dimStyle))));
+ } else {
+ Style statusStyle = switch (extractState(info.state)) {
+ case "Started" -> Style.create().fg(Color.GREEN);
+ case "Stopped" -> Style.create().fg(Color.RED);
+ default -> Style.create().fg(Color.YELLOW);
+ };
+
+ rows.add(Row.from(
+ Cell.from(info.pid),
+ Cell.from(Span.styled(truncate(info.name, 25), Style.create().fg(Color.CYAN))),
+ Cell.from(info.platform != null ? info.platform : ""),
+ Cell.from(Span.styled(extractState(info.state), statusStyle)),
+ Cell.from(info.ago != null ? info.ago : ""),
+ Cell.from(info.throughput != null ? info.throughput : ""),
+ Cell.from(formatMemory(info.heapMemUsed, info.heapMemMax)),
+ Cell.from(formatThreads(info.threadCount, info.peakThreadCount))));
+ }
+ }
+
+ Row header = Row.from(
+ Cell.from(Span.styled("PID", Style.create().bold())),
+ Cell.from(Span.styled("NAME", Style.create().bold())),
+ Cell.from(Span.styled("PLATFORM", Style.create().bold())),
+ Cell.from(Span.styled("STATUS", Style.create().bold())),
+ Cell.from(Span.styled("AGE", Style.create().bold())),
+ Cell.from(Span.styled("THRUPUT", Style.create().bold())),
+ Cell.from(Span.styled("HEAP", Style.create().bold())),
+ Cell.from(Span.styled("THREADS", Style.create().bold())));
+
+ Table table = Table.builder()
+ .rows(rows)
+ .header(header)
+ .widths(
+ Constraint.length(8),
+ Constraint.fill(),
+ Constraint.length(10),
+ Constraint.length(10),
+ Constraint.length(8),
+ Constraint.length(10),
+ Constraint.length(15),
+ Constraint.length(12))
+ .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue())
+ .block(Block.builder().borderType(BorderType.ROUNDED).title(" Integrations ").build())
+ .build();
+
+ frame.renderStatefulWidget(table, chunks.get(0), overviewTableState);
+
+ // Bar chart for throughput
+ if (hasSparkline && chunks.size() > 1) {
+ // Merge all throughput histories for overview chart
+ LinkedList<Long> merged = new LinkedList<>();
+ for (int i = 0; i < MAX_SPARKLINE_POINTS; i++) {
+ long sum = 0;
+ for (LinkedList<Long> hist : throughputHistory.values()) {
+ if (i < hist.size()) {
+ sum += hist.get(hist.size() - 1 - i);
+ }
+ }
+ merged.addFirst(sum);
+ }
+
+ // Compute stats for title display
+ long maxTp = merged.stream().mapToLong(Long::longValue).max().orElse(0);
+ long curTp = merged.isEmpty() ? 0 : merged.get(merged.size() - 1);
+ String chartTitle = String.format(" Throughput: %d msg/s (peak: %d) ", curTp, maxTp);
+
+ // Build bar groups — one bar per data point
+ List<BarGroup> groups = new ArrayList<>();
+ for (Long value : merged) {
+ groups.add(BarGroup.of(Bar.of(value)));
+ }
+
+ BarChart barChart = BarChart.builder()
+ .data(groups)
+ .barWidth(1)
+ .barGap(0)
+ .barStyle(Style.create().fg(Color.GREEN))
+ .block(Block.builder().borderType(BorderType.ROUNDED).title(chartTitle).build())
+ .build();
+
+ frame.renderWidget(barChart, chunks.get(1));
+ }
+ }
+
+ // ---- Tab 2: Routes ----
+
+ private void renderRoutes(Frame frame, Rect area) {
+ IntegrationInfo info = findSelectedIntegration();
+ if (info == null) {
+ renderNoSelection(frame, area);
+ return;
+ }
+
+ // Split: routes table (top half) + processors table (bottom half)
+ List<Rect> chunks = Layout.vertical()
+ .constraints(Constraint.percentage(45), Constraint.percentage(55))
+ .split(area);
+
+ // Routes table
+ List<Row> routeRows = new ArrayList<>();
+ for (RouteInfo route : info.routes) {
+ Style stateStyle = "Started".equals(route.state)
+ ? Style.create().fg(Color.GREEN)
+ : Style.create().fg(Color.RED);
+
+ routeRows.add(Row.from(
+ Cell.from(Span.styled(truncate(route.routeId, 12), Style.create().fg(Color.CYAN))),
+ Cell.from(truncate(route.from, 30)),
+ Cell.from(Span.styled(route.state, stateStyle)),
+ Cell.from(route.uptime != null ? route.uptime : ""),
+ Cell.from(route.throughput != null ? route.throughput : ""),
+ Cell.from(String.valueOf(route.total)),
+ Cell.from(route.failed > 0
+ ? Span.styled(String.valueOf(route.failed), Style.create().fg(Color.RED))
+ : Span.raw("0")),
+ Cell.from(route.meanTime + "/" + route.maxTime)));
+ }
+
+ Table routeTable = Table.builder()
+ .rows(routeRows)
+ .header(Row.from(
+ Cell.from(Span.styled("ROUTE", Style.create().bold())),
+ Cell.from(Span.styled("FROM", Style.create().bold())),
+ Cell.from(Span.styled("STATE", Style.create().bold())),
+ Cell.from(Span.styled("UPTIME", Style.create().bold())),
+ Cell.from(Span.styled("THRUPUT", Style.create().bold())),
+ Cell.from(Span.styled("TOTAL", Style.create().bold())),
+ Cell.from(Span.styled("FAILED", Style.create().bold())),
+ Cell.from(Span.styled("MEAN/MAX", Style.create().bold()))))
+ .widths(
+ Constraint.length(12),
+ Constraint.fill(),
+ Constraint.length(10),
+ Constraint.length(8),
+ Constraint.length(10),
+ Constraint.length(8),
+ Constraint.length(8),
+ Constraint.length(12))
+ .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue())
+ .block(Block.builder().borderType(BorderType.ROUNDED)
+ .title(" Routes [" + info.name + "] ").build())
+ .build();
+
+ frame.renderStatefulWidget(routeTable, chunks.get(0), routeTableState);
+
+ // Processors for selected route
+ Integer selectedRoute = routeTableState.selected();
+ if (selectedRoute != null && selectedRoute >= 0 && selectedRoute < info.routes.size()) {
+ RouteInfo route = info.routes.get(selectedRoute);
+ renderProcessors(frame, chunks.get(1), route);
+ } else if (!info.routes.isEmpty()) {
+ renderProcessors(frame, chunks.get(1), info.routes.get(0));
+ } else {
+ frame.renderWidget(
+ Paragraph.builder()
+ .text(Text.from(Line.from(Span.styled("No routes", Style.create().dim()))))
+ .block(Block.builder().borderType(BorderType.ROUNDED).title(" Processors ").build())
+ .build(),
+ chunks.get(1));
+ }
+ }
+
+ private void renderProcessors(Frame frame, Rect area, RouteInfo route) {
+ List<Row> rows = new ArrayList<>();
+ for (ProcessorInfo proc : route.processors) {
+ String indent = " ".repeat(proc.level);
+ Style nameStyle = proc.failed > 0 ? Style.create().fg(Color.RED) : Style.create().fg(Color.CYAN);
+
+ rows.add(Row.from(
+ Cell.from(Span.styled(indent + proc.id, nameStyle)),
+ Cell.from(proc.processor),
+ Cell.from(String.valueOf(proc.total)),
+ Cell.from(proc.failed > 0
+ ? Span.styled(String.valueOf(proc.failed), Style.create().fg(Color.RED))
+ : Span.raw("0")),
+ Cell.from(proc.meanTime + "ms"),
+ Cell.from(proc.maxTime + "ms"),
+ Cell.from(proc.lastTime + "ms")));
+ }
+
+ Table table = Table.builder()
+ .rows(rows)
+ .header(Row.from(
+ Cell.from(Span.styled("PROCESSOR", Style.create().bold())),
+ Cell.from(Span.styled("TYPE", Style.create().bold())),
+ Cell.from(Span.styled("TOTAL", Style.create().bold())),
+ Cell.from(Span.styled("FAILED", Style.create().bold())),
+ Cell.from(Span.styled("MEAN", Style.create().bold())),
+ Cell.from(Span.styled("MAX", Style.create().bold())),
+ Cell.from(Span.styled("LAST", Style.create().bold()))))
+ .widths(
+ Constraint.fill(),
+ Constraint.length(15),
+ Constraint.length(8),
+ Constraint.length(8),
+ Constraint.length(8),
+ Constraint.length(8),
+ Constraint.length(8))
+ .block(Block.builder().borderType(BorderType.ROUNDED)
+ .title(" Processors [" + route.routeId + "] ").build())
+ .build();
+
+ frame.renderStatefulWidget(table, area, new TableState());
+ }
+
+ // ---- Tab 3: Health ----
+
+ private void renderHealth(Frame frame, Rect area) {
+ IntegrationInfo info = findSelectedIntegration();
+ if (info == null) {
+ renderNoSelection(frame, area);
+ return;
+ }
+
+ // Split: health table (fill) + memory gauge (3 rows)
+ List<Rect> chunks = Layout.vertical()
+ .constraints(Constraint.fill(), Constraint.length(3))
+ .split(area);
+
+ List<Row> rows = new ArrayList<>();
+ for (HealthCheckInfo hc : info.healthChecks) {
+ Style stateStyle;
+ String icon;
+ if ("UP".equals(hc.state)) {
+ stateStyle = Style.create().fg(Color.GREEN);
+ icon = "\u2714 ";
+ } else if ("DOWN".equals(hc.state)) {
+ stateStyle = Style.create().fg(Color.RED);
+ icon = "\u2716 ";
+ } else {
+ stateStyle = Style.create().fg(Color.YELLOW);
+ icon = "\u26A0 ";
+ }
+
+ rows.add(Row.from(
+ Cell.from(Span.styled(truncate(hc.group != null ? hc.group : "", 12), Style.create().dim())),
+ Cell.from(Span.styled(truncate(hc.name, 30), Style.create().fg(Color.CYAN))),
+ Cell.from(Span.styled(icon + hc.state, stateStyle)),
+ Cell.from(hc.message != null ? truncate(hc.message, 50) : "")));
+ }
+
+ if (rows.isEmpty()) {
+ rows.add(Row.from(
+ Cell.from(""),
+ Cell.from(Span.styled("No health checks registered", Style.create().dim())),
+ Cell.from(""),
+ Cell.from("")));
+ }
+
+ Table table = Table.builder()
+ .rows(rows)
+ .header(Row.from(
+ Cell.from(Span.styled("GROUP", Style.create().bold())),
+ Cell.from(Span.styled("NAME", Style.create().bold())),
+ Cell.from(Span.styled("STATUS", Style.create().bold())),
+ Cell.from(Span.styled("MESSAGE", Style.create().bold()))))
+ .widths(
+ Constraint.length(12),
+ Constraint.length(30),
+ Constraint.length(12),
+ Constraint.fill())
+ .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue())
+ .block(Block.builder().borderType(BorderType.ROUNDED)
+ .title(" Health [" + info.name + "] ").build())
+ .build();
+
+ frame.renderStatefulWidget(table, chunks.get(0), healthTableState);
+
+ // Memory gauge
+ if (info.heapMemMax > 0) {
+ int pct = (int) (100.0 * info.heapMemUsed / info.heapMemMax);
+ Style gaugeStyle = pct > 80 ? Style.create().fg(Color.RED)
+ : pct > 60 ? Style.create().fg(Color.YELLOW) : Style.create().fg(Color.GREEN);
+ Gauge gauge = Gauge.builder()
+ .percent(pct)
+ .label(String.format("Heap: %s / %s (%d%%)", formatBytes(info.heapMemUsed),
+ formatBytes(info.heapMemMax), pct))
+ .gaugeStyle(gaugeStyle)
+ .block(Block.builder().borderType(BorderType.ROUNDED).build())
+ .build();
+
+ frame.renderWidget(gauge, chunks.get(1));
+ }
+ }
+
+ // ---- Tab 4: Endpoints ----
+
+ private void renderEndpoints(Frame frame, Rect area) {
+ IntegrationInfo info = findSelectedIntegration();
+ if (info == null) {
+ renderNoSelection(frame, area);
+ return;
+ }
+
+ List<Row> rows = new ArrayList<>();
+ for (EndpointInfo ep : info.endpoints) {
+ Style dirStyle = switch (ep.direction) {
+ case "in" -> Style.create().fg(Color.GREEN);
+ case "out" -> Style.create().fg(Color.BLUE);
+ default -> Style.create().fg(Color.YELLOW);
+ };
+ String arrow = switch (ep.direction) {
+ case "in" -> "\u2192 ";
+ case "out" -> "\u2190 ";
+ default -> "\u2194 ";
+ };
+
+ rows.add(Row.from(
+ Cell.from(Span.styled(ep.component, Style.create().fg(Color.CYAN))),
+ Cell.from(Span.styled(arrow + ep.direction, dirStyle)),
+ Cell.from(truncate(ep.uri, 60)),
+ Cell.from(ep.routeId != null ? ep.routeId : "")));
+ }
+
+ if (rows.isEmpty()) {
+ rows.add(Row.from(
+ Cell.from(""),
+ Cell.from(Span.styled("No endpoints", Style.create().dim())),
+ Cell.from(""),
+ Cell.from("")));
+ }
+
+ Table table = Table.builder()
+ .rows(rows)
+ .header(Row.from(
+ Cell.from(Span.styled("COMPONENT", Style.create().bold())),
+ Cell.from(Span.styled("DIR", Style.create().bold())),
+ Cell.from(Span.styled("URI", Style.create().bold())),
+ Cell.from(Span.styled("ROUTE", Style.create().bold()))))
+ .widths(
+ Constraint.length(15),
+ Constraint.length(8),
+ Constraint.fill(),
+ Constraint.length(12))
+ .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue())
+ .block(Block.builder().borderType(BorderType.ROUNDED)
+ .title(" Endpoints [" + info.name + "] ").build())
+ .build();
+
+ frame.renderStatefulWidget(table, area, endpointTableState);
+ }
+
+ // ---- Tab 5: Log ----
+
+ private void renderLog(Frame frame, Rect area) {
+ IntegrationInfo info = findSelectedIntegration();
+ if (info == null) {
+ renderNoSelection(frame, area);
+ return;
+ }
+
+ // Read log lines
+ readLogFile(info.pid);
+
+ Block logBlock = Block.builder().borderType(BorderType.ROUNDED)
+ .title(" Log [" + info.name + "] ")
+ .build();
+
+ int innerHeight = Math.max(1, area.height() - 2); // account for border
+ int totalLines = logLines.size();
+
+ // Auto-scroll to bottom if logScroll is 0 (default)
+ int startLine;
+ if (logScroll == 0) {
+ startLine = Math.max(0, totalLines - innerHeight);
+ } else {
+ startLine = Math.max(0, Math.min(logScroll, totalLines - innerHeight));
+ }
+
+ List<Line> visibleLines = new ArrayList<>();
+ for (int i = startLine; i < Math.min(startLine + innerHeight, totalLines); i++) {
+ visibleLines.add(colorizeLogLine(logLines.get(i)));
+ }
+
+ // Fill remaining space
+ while (visibleLines.size() < innerHeight) {
+ visibleLines.add(Line.from(Span.raw("")));
+ }
+
+ Paragraph logParagraph = Paragraph.builder()
+ .text(Text.from(visibleLines))
+ .overflow(Overflow.CLIP)
+ .block(logBlock)
+ .build();
+
+ frame.renderWidget(logParagraph, area);
+ }
+
+ private Line colorizeLogLine(String line) {
+ if (line.contains(" ERROR ") || line.contains(" FATAL ")) {
+ return Line.from(Span.styled(line, Style.create().fg(Color.RED)));
+ } else if (line.contains(" WARN ")) {
+ return Line.from(Span.styled(line, Style.create().fg(Color.YELLOW)));
+ } else if (line.contains(" DEBUG ") || line.contains(" TRACE ")) {
+ return Line.from(Span.styled(line, Style.create().dim()));
+ }
+ return Line.from(Span.raw(line));
+ }
+
+ private void readLogFile(String pid) {
+ logLines.clear();
+ Path logFile = CommandLineHelper.getCamelDir().resolve(pid + ".log");
+ if (!Files.exists(logFile)) {
+ return;
+ }
+ try (RandomAccessFile raf = new RandomAccessFile(logFile.toFile(), "r")) {
+ long length = raf.length();
+ // Read last ~64KB to get recent lines
+ long startPos = Math.max(0, length - 64 * 1024);
+ raf.seek(startPos);
+ if (startPos > 0) {
+ raf.readLine(); // skip partial line
+ }
+ // Read remaining bytes and split into lines using proper encoding
+ byte[] remaining = new byte[(int) (length - raf.getFilePointer())];
+ raf.readFully(remaining);
+ String content = new String(remaining, java.nio.charset.StandardCharsets.UTF_8);
+ String[] lines = content.split("\n", -1);
+ int start = Math.max(0, lines.length - MAX_LOG_LINES);
+ for (int i = start; i < lines.length; i++) {
+ String line = lines[i];
+ if (!line.isEmpty()) {
+ logLines.add(line);
+ }
+ }
+ } catch (IOException e) {
+ // ignore
+ }
+ }
+
+ // ---- Shared rendering ----
+
+ private void renderNoSelection(Frame frame, Rect area) {
+ frame.renderWidget(
+ Paragraph.builder()
+ .text(Text.from(Line.from(
+ Span.styled(" Select an integration from the Overview tab (press 1)",
+ Style.create().dim()))))
+ .block(Block.builder().borderType(BorderType.ROUNDED)
+ .title(" No integration selected ").build())
+ .build(),
+ area);
+ }
+
+ private void renderFooter(Frame frame, Rect area) {
+ String refreshLabel = refreshInterval >= 1000
+ ? (refreshInterval / 1000) + "s"
+ : refreshInterval + "ms";
+ Line footer;
+ if (tabsState.selected() == TAB_OVERVIEW) {
+ footer = Line.from(
+ Span.styled(" q", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" quit "),
+ Span.styled("r", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" refresh "),
+ Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" navigate "),
+ Span.styled("Enter", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" details "),
+ Span.styled("1-5", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" tabs "),
+ Span.styled("Refresh: " + refreshLabel, Style.create().dim()));
+ } else if (tabsState.selected() == TAB_LOG) {
+ footer = Line.from(
+ Span.styled(" Esc", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" back "),
+ Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" scroll "),
+ Span.styled("PgUp/PgDn", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" page "),
+ Span.styled("1-5", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" tabs "),
+ Span.styled("Refresh: " + refreshLabel, Style.create().dim()));
+ } else {
+ footer = Line.from(
+ Span.styled(" Esc", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" back "),
+ Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" navigate "),
+ Span.styled("1-5", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" tabs "),
+ Span.styled("Refresh: " + refreshLabel, Style.create().dim()));
+ }
+
+ frame.renderWidget(Paragraph.from(footer), area);
+ }
+
+ // ---- Data Loading ----
+
+ private void refreshData() {
+ lastRefresh = System.currentTimeMillis();
+ try {
+ List<IntegrationInfo> infos = new ArrayList<>();
+ List<Long> pids = findPids(name);
+ ProcessHandle.allProcesses()
+ .filter(ph -> pids.contains(ph.pid()))
+ .forEach(ph -> {
+ JsonObject root = loadStatus(ph.pid());
+ if (root != null) {
+ IntegrationInfo info = parseIntegration(ph, root);
+ if (info != null) {
+ infos.add(info);
+ // Track throughput for sparkline
+ updateThroughputHistory(info);
+ }
+ }
+ });
+
+ // Detect disappeared integrations and start vanishing
+ Set<String> livePids = infos.stream().map(i -> i.pid).collect(Collectors.toSet());
+ List<IntegrationInfo> previous = data.get();
+ for (IntegrationInfo prev : previous) {
+ if (!prev.vanishing && !livePids.contains(prev.pid) && !vanishing.containsKey(prev.pid)) {
+ vanishing.put(prev.pid, new VanishingInfo(prev, System.currentTimeMillis()));
+ }
+ }
+
+ // Expire old vanishing entries
+ long now = System.currentTimeMillis();
+ Iterator<Map.Entry<String, VanishingInfo>> it = vanishing.entrySet().iterator();
+ while (it.hasNext()) {
+ Map.Entry<String, VanishingInfo> entry = it.next();
+ if (now - entry.getValue().startTime > VANISH_DURATION_MS) {
+ it.remove();
+ throughputHistory.remove(entry.getKey());
+ } else if (!livePids.contains(entry.getKey())) {
+ IntegrationInfo ghost = entry.getValue().info;
+ ghost.vanishing = true;
+ ghost.vanishStart = entry.getValue().startTime;
+ infos.add(ghost);
+ } else {
+ it.remove();
+ }
+ }
+
+ data.set(infos);
+ } catch (Exception e) {
+ // ignore refresh errors
+ }
+ }
+
+ private void updateThroughputHistory(IntegrationInfo info) {
+ // Track exchangesTotal over a 1-second sliding window for stable throughput
+ long currentTotal = info.exchangesTotal;
+ long now = System.currentTimeMillis();
+
+ String pid = info.pid;
+ LinkedList<long[]> samples = throughputSamples.computeIfAbsent(pid, k -> new LinkedList<>());
+ samples.add(new long[] { now, currentTotal });
+
+ // Remove samples older than 1 second
+ while (!samples.isEmpty() && now - samples.get(0)[0] > 1000) {
+ samples.remove(0);
+ }
+
+ // Compute throughput over the window
+ if (samples.size() >= 2) {
+ long[] oldest = samples.get(0);
+ long[] newest = samples.get(samples.size() - 1);
+ long deltaExchanges = newest[1] - oldest[1];
+ long deltaTimeMs = newest[0] - oldest[0];
+ long tp = deltaTimeMs > 0 ? (deltaExchanges * 1000) / deltaTimeMs : 0;
+
+ LinkedList<Long> hist = throughputHistory.computeIfAbsent(pid, k -> new LinkedList<>());
+ // Only add one point per second to keep the sparkline meaningful
+ Long lastTime = previousExchangesTime.get(pid);
+ if (lastTime == null || now - lastTime >= 1000) {
+ previousExchangesTime.put(pid, now);
+ hist.add(tp);
+ while (hist.size() > MAX_SPARKLINE_POINTS) {
+ hist.remove(0);
+ }
+ }
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private IntegrationInfo parseIntegration(ProcessHandle ph, JsonObject root) {
+ JsonObject context = (JsonObject) root.get("context");
+ if (context == null) {
+ return null;
+ }
+
+ IntegrationInfo info = new IntegrationInfo();
+ info.name = context.getString("name");
+ if ("CamelJBang".equals(info.name)) {
+ info.name = ProcessHelper.extractName(root, ph);
+ }
+ info.pid = Long.toString(ph.pid());
+ info.uptime = extractSince(ph);
+ info.ago = TimeUtils.printSince(info.uptime);
+ info.state = context.getInteger("phase");
+
+ JsonObject runtime = (JsonObject) root.get("runtime");
+ info.platform = runtime != null ? runtime.getString("platform") : null;
+
+ Map<String, ?> stats = context.getMap("statistics");
+ if (stats != null) {
+ Object thp = stats.get("exchangesThroughput");
+ if (thp != null) {
+ info.throughput = thp.toString();
+ }
+ info.exchangesTotal = objToLong(stats.get("exchangesTotal"));
+ }
+
+ JsonObject mem = (JsonObject) root.get("memory");
+ if (mem != null) {
+ info.heapMemUsed = mem.getLong("heapMemoryUsed");
+ info.heapMemMax = mem.getLong("heapMemoryMax");
+ }
+
+ JsonObject threads = (JsonObject) root.get("threads");
+ if (threads != null) {
+ info.threadCount = threads.getInteger("threadCount");
+ info.peakThreadCount = threads.getInteger("peakThreadCount");
+ }
+
+ // Parse routes
+ JsonArray routes = (JsonArray) root.get("routes");
+ if (routes != null) {
+ for (Object r : routes) {
+ JsonObject rj = (JsonObject) r;
+ RouteInfo ri = new RouteInfo();
+ ri.routeId = rj.getString("routeId");
+ ri.from = rj.getString("from");
+ ri.state = rj.getString("state");
+ ri.uptime = rj.getString("uptime");
+
+ Map<String, ?> rs = rj.getMap("statistics");
+ if (rs != null) {
+ ri.throughput = objToString(rs.get("exchangesThroughput"));
+ ri.total = objToLong(rs.get("exchangesTotal"));
+ ri.failed = objToLong(rs.get("exchangesFailed"));
+ ri.meanTime = objToLong(rs.get("meanProcessingTime"));
+ ri.maxTime = objToLong(rs.get("maxProcessingTime"));
+ }
+
+ // Parse processors
+ JsonArray procs = (JsonArray) rj.get("processors");
+ if (procs != null) {
+ for (Object p : procs) {
+ JsonObject pj = (JsonObject) p;
+ ProcessorInfo pi = new ProcessorInfo();
+ pi.id = pj.getString("id");
+ pi.processor = pj.getString("processor");
+ pi.level = pj.getIntegerOrDefault("level", 0);
+
+ Map<String, ?> ps = pj.getMap("statistics");
+ if (ps != null) {
+ pi.total = objToLong(ps.get("exchangesTotal"));
+ pi.failed = objToLong(ps.get("exchangesFailed"));
+ pi.meanTime = objToLong(ps.get("meanProcessingTime"));
+ pi.maxTime = objToLong(ps.get("maxProcessingTime"));
+ pi.lastTime = objToLong(ps.get("lastProcessingTime"));
+ }
+
+ ri.processors.add(pi);
+ }
+ }
+
+ info.routes.add(ri);
+ }
+ }
+
+ // Parse health checks
+ JsonObject healthChecks = (JsonObject) root.get("healthChecks");
+ if (healthChecks != null) {
+ JsonArray checks = (JsonArray) healthChecks.get("checks");
+ if (checks != null) {
+ for (Object c : checks) {
+ JsonObject cj = (JsonObject) c;
+ HealthCheckInfo hc = new HealthCheckInfo();
+ hc.group = cj.getString("group");
+ hc.name = cj.getString("id");
+ hc.state = cj.getString("state");
+ // Extract message from details if available
+ JsonObject details = (JsonObject) cj.get("details");
+ if (details != null && details.containsKey("failure.error.message")) {
+ hc.message = details.getString("failure.error.message");
+ }
+ info.healthChecks.add(hc);
+ }
+ }
+ }
+
+ // Parse endpoints (top-level "endpoints" is a JsonObject with nested "endpoints" array)
+ JsonObject endpointsObj = (JsonObject) root.get("endpoints");
+ if (endpointsObj != null) {
+ JsonArray endpointList = (JsonArray) endpointsObj.get("endpoints");
+ if (endpointList != null) {
+ for (Object e : endpointList) {
+ JsonObject ej = (JsonObject) e;
+ EndpointInfo ep = new EndpointInfo();
+ ep.uri = ej.getString("uri");
+ ep.direction = ej.getString("direction");
+ ep.routeId = ej.getString("routeId");
+ // Extract component from URI (e.g., "timer://tick" -> "timer")
+ if (ep.uri != null) {
+ int idx = ep.uri.indexOf(':');
+ ep.component = idx > 0 ? ep.uri.substring(0, idx) : ep.uri;
+ }
+ info.endpoints.add(ep);
+ }
+ }
+ }
+
+ return info;
+ }
+
+ // ---- Helpers ----
+
+ private IntegrationInfo findSelectedIntegration() {
+ if (selectedPid == null) {
+ return null;
+ }
+ return data.get().stream()
+ .filter(i -> selectedPid.equals(i.pid) && !i.vanishing)
+ .findFirst().orElse(null);
+ }
+
+ private String selectedName() {
+ IntegrationInfo info = findSelectedIntegration();
+ return info != null ? truncate(info.name, 20) : "?";
+ }
+
+ private List<Long> findPids(String name) {
+ List<Long> pids = new ArrayList<>();
+ final long cur = ProcessHandle.current().pid();
+ String pattern = name;
+ if (!pattern.matches("\\d+") && !pattern.endsWith("*")) {
+ pattern = pattern + "*";
+ }
+ final String pat = pattern;
+ ProcessHandle.allProcesses()
+ .filter(ph -> ph.pid() != cur)
+ .forEach(ph -> {
+ JsonObject root = loadStatus(ph.pid());
+ if (root != null) {
+ String pName = ProcessHelper.extractName(root, ph);
+ pName = FileUtil.onlyName(pName);
+ if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) {
+ pids.add(ph.pid());
+ } else {
+ JsonObject context = (JsonObject) root.get("context");
+ if (context != null) {
+ pName = context.getString("name");
+ if ("CamelJBang".equals(pName)) {
+ pName = null;
+ }
+ if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) {
+ pids.add(ph.pid());
+ }
+ }
+ }
+ }
+ });
+ return pids;
+ }
+
+ private JsonObject loadStatus(long pid) {
+ try {
+ Path f = getStatusFile(Long.toString(pid));
+ if (f != null && Files.exists(f)) {
+ String text = Files.readString(f);
+ return (JsonObject) Jsoner.deserialize(text);
+ }
+ } catch (Exception e) {
+ // ignore
+ }
+ return null;
+ }
+
+ private static long extractSince(ProcessHandle ph) {
+ return ph.info().startInstant().map(Instant::toEpochMilli).orElse(0L);
+ }
+
+ private static String truncate(String s, int max) {
+ if (s == null) {
+ return "";
+ }
+ return s.length() > max ? s.substring(0, max - 1) + "\u2026" : s;
+ }
+
+ private static String formatMemory(long used, long max) {
+ if (used <= 0) {
+ return "";
+ }
+ String u = formatBytes(used);
+ if (max > 0) {
+ return u + "/" + formatBytes(max);
+ }
+ return u;
+ }
+
+ private static String formatBytes(long bytes) {
+ if (bytes < 1024) {
+ return bytes + "B";
+ }
+ if (bytes < 1024 * 1024) {
+ return (bytes / 1024) + "K";
+ }
+ return (bytes / (1024 * 1024)) + "M";
+ }
+
+ private static String formatThreads(int count, int peak) {
+ if (count <= 0) {
+ return "";
+ }
+ return count + "/" + peak;
+ }
+
+ private static String objToString(Object o) {
+ return o != null ? o.toString() : "";
+ }
+
+ private static long objToLong(Object o) {
+ if (o instanceof Number n) {
+ return n.longValue();
+ }
+ if (o != null) {
+ try {
+ return Long.parseLong(o.toString());
+ } catch (NumberFormatException e) {
+ // ignore
+ }
+ }
+ return 0;
+ }
+
+ // ---- Data Classes ----
+
+ static class IntegrationInfo {
+ String pid;
+ String name;
+ String platform;
+ int state;
+ long uptime;
+ String ago;
+ String throughput;
+ long exchangesTotal;
+ long heapMemUsed;
+ long heapMemMax;
+ int threadCount;
+ int peakThreadCount;
+ boolean vanishing;
+ long vanishStart;
+ final List<RouteInfo> routes = new ArrayList<>();
+ final List<HealthCheckInfo> healthChecks = new ArrayList<>();
+ final List<EndpointInfo> endpoints = new ArrayList<>();
+ }
+
+ static class RouteInfo {
+ String routeId;
+ String from;
+ String state;
+ String uptime;
+ String throughput;
+ long total;
+ long failed;
+ long meanTime;
+ long maxTime;
+ final List<ProcessorInfo> processors = new ArrayList<>();
+ }
+
+ static class ProcessorInfo {
+ String id;
+ String processor;
+ int level;
+ long total;
+ long failed;
+ long meanTime;
+ long maxTime;
+ long lastTime;
+ }
+
+ static class HealthCheckInfo {
+ String group;
+ String name;
+ String state;
+ String message;
+ }
+
+ static class EndpointInfo {
+ String uri;
+ String component;
+ String direction;
+ String routeId;
+ }
+
+ record VanishingInfo(IntegrationInfo info, long startTime) {
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTopTui.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTopTui.java
new file mode 100644
index 000000000000..54305b18a31c
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTopTui.java
@@ -0,0 +1,488 @@
+/*
+ * 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.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+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.TuiRunner;
+import dev.tamboui.tui.event.Event;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.TickEvent;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import dev.tamboui.widgets.table.Cell;
+import dev.tamboui.widgets.table.Row;
+import dev.tamboui.widgets.table.Table;
+import dev.tamboui.widgets.table.TableState;
+import org.apache.camel.dsl.jbang.core.commands.CamelCommand;
+import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.dsl.jbang.core.common.ProcessHelper;
+import org.apache.camel.support.PatternHelper;
+import org.apache.camel.util.FileUtil;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+
+@Command(name = "top-tui",
+ description = "Live TUI dashboard for route performance",
+ sortOptions = false)
+public class CamelTopTui extends CamelCommand {
+
+ private static final String[] SORT_COLUMNS = { "mean", "max", "total", "failed", "name" };
+
+ @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1")
+ String name = "*";
+
+ @CommandLine.Option(names = { "--refresh" },
+ description = "Refresh interval in milliseconds (default: ${DEFAULT-VALUE})",
+ defaultValue = "500")
+ long refreshInterval = 500;
+
+ @CommandLine.Option(names = { "--sort" },
+ description = "Sort column: mean, max, total, failed, name (default: ${DEFAULT-VALUE})",
+ defaultValue = "mean")
+ String sort = "mean";
+
+ // State
+ private final AtomicReference<List<RouteRow>> data = new AtomicReference<>(Collections.emptyList());
+ private final TableState tableState = new TableState();
+ private int sortIndex;
+ private volatile long lastRefresh;
+
+ public CamelTopTui(CamelJBangMain main) {
+ super(main);
+ }
+
+ @Override
+ public Integer doCall() throws Exception {
+ // Eagerly load classes used by the input reader thread and picocli
+ // post-processing to avoid ClassNotFoundException during shutdown
+ try {
+ Class.forName("dev.tamboui.tui.event.KeyModifiers");
+ Class.forName("dev.tamboui.tui.event.KeyEvent");
+ Class.forName("dev.tamboui.tui.event.KeyCode");
+ Class.forName("picocli.CommandLine$IExitCodeGenerator");
+ } catch (ClassNotFoundException e) {
+ // ignore
+ }
+
+ // Resolve initial sort index
+ sortIndex = indexOfSort(sort);
+
+ // Initial data load
+ refreshData();
+
+ try (var tui = TuiRunner.create()) {
+ sun.misc.Signal.handle(new sun.misc.Signal("INT"), sig -> tui.quit());
+ tui.run(this::handleEvent, this::render);
+ }
+ return 0;
+ }
+
+ // ---- Event Handling ----
+
+ private boolean handleEvent(Event event, TuiRunner runner) {
+ if (event instanceof KeyEvent ke) {
+ if (ke.isQuit() || ke.isCharIgnoreCase('q') || ke.isKey(KeyCode.ESCAPE)) {
+ runner.quit();
+ return true;
+ }
+ if (ke.isChar('r')) {
+ refreshData();
+ return true;
+ }
+ if (ke.isCharIgnoreCase('s')) {
+ // Cycle sort column
+ sortIndex = (sortIndex + 1) % SORT_COLUMNS.length;
+ sort = SORT_COLUMNS[sortIndex];
+ sortData();
+ return true;
+ }
+ if (ke.isUp()) {
+ tableState.selectPrevious();
+ return true;
+ }
+ if (ke.isDown()) {
+ tableState.selectNext(data.get().size());
+ return true;
+ }
+ }
+ if (event instanceof TickEvent) {
+ long now = System.currentTimeMillis();
+ if (now - lastRefresh >= refreshInterval) {
+ refreshData();
+ }
+ return true;
+ }
+ return false;
+ }
+
+ // ---- Rendering ----
+
+ private void render(Frame frame) {
+ Rect area = frame.area();
+
+ // Layout: header (3 rows) + table (fill) + footer (1 row)
+ List<Rect> chunks = Layout.vertical()
+ .constraints(
+ Constraint.length(3),
+ Constraint.fill(),
+ Constraint.length(1))
+ .split(area);
+
+ renderHeader(frame, chunks.get(0));
+ renderTable(frame, chunks.get(1));
+ renderFooter(frame, chunks.get(2));
+ }
+
+ private void renderHeader(Frame frame, Rect area) {
+ List<RouteRow> rows = data.get();
+ long totalExchanges = rows.stream().mapToLong(r -> r.total).sum();
+ long totalFailed = rows.stream().mapToLong(r -> r.failed).sum();
+ int routeCount = rows.size();
+ long pidCount = rows.stream().map(r -> r.pid).distinct().count();
+
+ Line titleLine = Line.from(
+ Span.styled(" Camel Top", Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)).bold()),
+ Span.raw(" "),
+ Span.styled(pidCount + " integration(s)", Style.create().fg(Color.CYAN)),
+ Span.raw(" "),
+ Span.styled(routeCount + " route(s)", Style.create().fg(Color.GREEN)),
+ Span.raw(" "),
+ Span.styled("total: " + totalExchanges, Style.create().fg(Color.WHITE)),
+ Span.raw(" "),
+ Span.styled("failed: " + totalFailed,
+ totalFailed > 0 ? Style.create().fg(Color.RED).bold() : Style.create().dim()),
+ Span.raw(" "),
+ Span.styled("sort: " + sort, Style.create().fg(Color.YELLOW)));
+
+ Block headerBlock = Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(" Apache Camel - Route Performance ")
+ .build();
+
+ frame.renderWidget(
+ Paragraph.builder().text(Text.from(titleLine)).block(headerBlock).build(),
+ area);
+ }
+
+ private void renderTable(Frame frame, Rect area) {
+ List<RouteRow> rows = data.get();
+
+ List<Row> tableRows = new ArrayList<>();
+ for (RouteRow r : rows) {
+ Style statusStyle = "Started".equals(r.state)
+ ? Style.create().fg(Color.GREEN)
+ : Style.create().fg(Color.RED);
+
+ Style failStyle = r.failed > 0
+ ? Style.create().fg(Color.RED).bold()
+ : Style.create();
+
+ tableRows.add(Row.from(
+ Cell.from(r.pid),
+ Cell.from(Span.styled(truncate(r.name, 20), Style.create().fg(Color.CYAN))),
+ Cell.from(Span.styled(truncate(r.routeId, 18), Style.create().fg(Color.WHITE))),
+ Cell.from(truncate(r.from, 30)),
+ Cell.from(Span.styled(r.state != null ? r.state : "", statusStyle)),
+ Cell.from(String.valueOf(r.total)),
+ Cell.from(Span.styled(String.valueOf(r.failed), failStyle)),
+ Cell.from(String.valueOf(r.inflight)),
+ Cell.from(r.mean >= 0 ? String.valueOf(r.mean) : ""),
+ Cell.from(r.min >= 0 ? String.valueOf(r.min) : ""),
+ Cell.from(r.max >= 0 ? String.valueOf(r.max) : ""),
+ Cell.from(r.last >= 0 ? String.valueOf(r.last) : ""),
+ Cell.from(r.throughput != null ? r.throughput : "")));
+ }
+
+ Row header = Row.from(
+ Cell.from(Span.styled("PID", Style.create().bold())),
+ Cell.from(Span.styled("NAME", Style.create().bold())),
+ Cell.from(Span.styled("ROUTE", Style.create().bold())),
+ Cell.from(Span.styled("FROM", Style.create().bold())),
+ Cell.from(Span.styled("STATUS", Style.create().bold())),
+ Cell.from(Span.styled("TOTAL", Style.create().bold())),
+ Cell.from(Span.styled("FAIL", Style.create().bold())),
+ Cell.from(Span.styled("INFLIGHT", Style.create().bold())),
+ Cell.from(Span.styled(sortLabel("MEAN", "mean"), sortStyle("mean"))),
+ Cell.from(Span.styled(sortLabel("MIN", "min"), sortStyle("min"))),
+ Cell.from(Span.styled(sortLabel("MAX", "max"), sortStyle("max"))),
+ Cell.from(Span.styled("LAST", Style.create().bold())),
+ Cell.from(Span.styled("THRUPUT", Style.create().bold())));
+
+ Table table = Table.builder()
+ .rows(tableRows)
+ .header(header)
+ .widths(
+ Constraint.length(8), // PID
+ Constraint.length(20), // NAME
+ Constraint.length(18), // ROUTE
+ Constraint.fill(), // FROM
+ Constraint.length(9), // STATUS
+ Constraint.length(8), // TOTAL
+ Constraint.length(6), // FAIL
+ Constraint.length(9), // INFLIGHT
+ Constraint.length(6), // MEAN
+ Constraint.length(6), // MIN
+ Constraint.length(6), // MAX
+ Constraint.length(6), // LAST
+ Constraint.length(9)) // THRUPUT
+ .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue())
+ .block(Block.builder().borderType(BorderType.ROUNDED).title(" Routes ").build())
+ .build();
+
+ frame.renderStatefulWidget(table, area, tableState);
+ }
+
+ private void renderFooter(Frame frame, Rect area) {
+ String refreshLabel = refreshInterval >= 1000
+ ? (refreshInterval / 1000) + "s"
+ : refreshInterval + "ms";
+
+ Line footer = Line.from(
+ Span.styled(" q", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" quit "),
+ Span.styled("s", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" sort "),
+ Span.styled("r", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" refresh "),
+ Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" navigate "),
+ Span.styled("Refresh: " + refreshLabel, Style.create().dim()));
+
+ frame.renderWidget(Paragraph.from(footer), area);
+ }
+
+ // ---- Data Loading ----
+
+ @SuppressWarnings("unchecked")
+ private void refreshData() {
+ lastRefresh = System.currentTimeMillis();
+ try {
+ List<RouteRow> rows = new ArrayList<>();
+ List<Long> pids = findPids(name);
+ ProcessHandle.allProcesses()
+ .filter(ph -> pids.contains(ph.pid()))
+ .forEach(ph -> {
+ JsonObject root = loadStatus(ph.pid());
+ if (root != null) {
+ JsonObject context = (JsonObject) root.get("context");
+ if (context == null) {
+ return;
+ }
+ String integrationName = context.getString("name");
+ if ("CamelJBang".equals(integrationName)) {
+ integrationName = ProcessHelper.extractName(root, ph);
+ }
+ String pid = Long.toString(ph.pid());
+
+ JsonArray routesArray = (JsonArray) root.get("routes");
+ if (routesArray != null) {
+ for (Object r : routesArray) {
+ JsonObject rj = (JsonObject) r;
+ RouteRow row = new RouteRow();
+ row.pid = pid;
+ row.name = integrationName;
+ row.routeId = rj.getString("routeId");
+ row.from = rj.getString("from");
+ row.state = rj.getString("state");
+
+ Map<String, ?> stats = rj.getMap("statistics");
+ if (stats != null) {
+ row.total = objToLong(stats.get("exchangesTotal"));
+ row.failed = objToLong(stats.get("exchangesFailed"));
+ row.inflight = objToLong(stats.get("exchangesInflight"));
+ row.mean = objToLong(stats.get("meanProcessingTime"));
+ row.min = objToLong(stats.get("minProcessingTime"));
+ row.max = objToLong(stats.get("maxProcessingTime"));
+ row.last = objToLong(stats.get("lastProcessingTime"));
+ Object thp = stats.get("exchangesThroughput");
+ if (thp != null) {
+ row.throughput = thp.toString();
+ }
+ }
+ rows.add(row);
+ }
+ }
+ }
+ });
+
+ // Sort
+ rows.sort(this::sortRow);
+ data.set(rows);
+ } catch (Exception e) {
+ // ignore refresh errors
+ }
+ }
+
+ private void sortData() {
+ List<RouteRow> rows = new ArrayList<>(data.get());
+ rows.sort(this::sortRow);
+ data.set(rows);
+ }
+
+ private int sortRow(RouteRow o1, RouteRow o2) {
+ switch (sort) {
+ case "mean":
+ return Long.compare(o2.mean, o1.mean); // highest first
+ case "max":
+ return Long.compare(o2.max, o1.max);
+ case "total":
+ return Long.compare(o2.total, o1.total);
+ case "failed":
+ return Long.compare(o2.failed, o1.failed);
+ case "name":
+ int c = o1.name != null && o2.name != null
+ ? o1.name.compareToIgnoreCase(o2.name)
+ : 0;
+ if (c == 0) {
+ c = o1.routeId != null && o2.routeId != null
+ ? o1.routeId.compareToIgnoreCase(o2.routeId)
+ : 0;
+ }
+ return c;
+ default:
+ return 0;
+ }
+ }
+
+ // ---- Helpers ----
+
+ private List<Long> findPids(String name) {
+ List<Long> pids = new ArrayList<>();
+ final long cur = ProcessHandle.current().pid();
+ String pattern = name;
+ if (!pattern.matches("\\d+") && !pattern.endsWith("*")) {
+ pattern = pattern + "*";
+ }
+ final String pat = pattern;
+ ProcessHandle.allProcesses()
+ .filter(ph -> ph.pid() != cur)
+ .forEach(ph -> {
+ JsonObject root = loadStatus(ph.pid());
+ if (root != null) {
+ String pName = ProcessHelper.extractName(root, ph);
+ pName = FileUtil.onlyName(pName);
+ if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) {
+ pids.add(ph.pid());
+ } else {
+ JsonObject context = (JsonObject) root.get("context");
+ if (context != null) {
+ pName = context.getString("name");
+ if ("CamelJBang".equals(pName)) {
+ pName = null;
+ }
+ if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) {
+ pids.add(ph.pid());
+ }
+ }
+ }
+ }
+ });
+ return pids;
+ }
+
+ private JsonObject loadStatus(long pid) {
+ try {
+ Path f = getStatusFile(Long.toString(pid));
+ if (f != null && Files.exists(f)) {
+ String text = Files.readString(f);
+ return (JsonObject) Jsoner.deserialize(text);
+ }
+ } catch (Exception e) {
+ // ignore
+ }
+ return null;
+ }
+
+ private static int indexOfSort(String s) {
+ for (int i = 0; i < SORT_COLUMNS.length; i++) {
+ if (SORT_COLUMNS[i].equals(s)) {
+ return i;
+ }
+ }
+ return 0;
+ }
+
+ private String sortLabel(String label, String column) {
+ return sort.equals(column) ? label + "\u25BC" : label;
+ }
+
+ private Style sortStyle(String column) {
+ return sort.equals(column)
+ ? Style.create().fg(Color.YELLOW).bold()
+ : Style.create().bold();
+ }
+
+ private static String truncate(String s, int max) {
+ if (s == null) {
+ return "";
+ }
+ return s.length() > max ? s.substring(0, max - 1) + "\u2026" : s;
+ }
+
+ private static long objToLong(Object o) {
+ if (o instanceof Number n) {
+ return n.longValue();
+ }
+ if (o != null) {
+ try {
+ return Long.parseLong(o.toString());
+ } catch (NumberFormatException e) {
+ // ignore
+ }
+ }
+ return 0;
+ }
+
+ // ---- Data Class ----
+
+ static class RouteRow {
+ String pid;
+ String name;
+ String routeId;
+ String from;
+ String state;
+ long total;
+ long failed;
+ long inflight;
+ long mean;
+ long min;
+ long max;
+ long last;
+ String throughput;
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTraceTui.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTraceTui.java
new file mode 100644
index 000000000000..05d56543dcb4
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTraceTui.java
@@ -0,0 +1,635 @@
+/*
+ * 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.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+import dev.tamboui.style.Overflow;
+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.TuiRunner;
+import dev.tamboui.tui.event.Event;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.TickEvent;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import dev.tamboui.widgets.table.Cell;
+import dev.tamboui.widgets.table.Row;
+import dev.tamboui.widgets.table.Table;
+import dev.tamboui.widgets.table.TableState;
+import org.apache.camel.dsl.jbang.core.commands.CamelCommand;
+import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.apache.camel.dsl.jbang.core.common.ProcessHelper;
+import org.apache.camel.support.PatternHelper;
+import org.apache.camel.util.FileUtil;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+
+@Command(name = "trace-tui",
+ description = "TUI exchange trace viewer",
+ sortOptions = false)
+public class CamelTraceTui extends CamelCommand {
+
+ private static final int MAX_TRACES = 200;
+
+ @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1")
+ String name = "*";
+
+ @CommandLine.Option(names = { "--refresh" },
+ description = "Refresh interval in milliseconds (default: ${DEFAULT-VALUE})",
+ defaultValue = "200")
+ long refreshInterval = 200;
+
+ @CommandLine.Option(names = { "--grep" },
+ description = "Filter traces by text")
+ String grep;
+
+ // State
+ private final AtomicReference<List<TraceEntry>> traces = new AtomicReference<>(Collections.emptyList());
+ private final TableState traceTableState = new TableState();
+ private final Map<String, Long> traceFilePositions = new LinkedHashMap<>();
+
+ private boolean showHeaders = true;
+ private boolean showBody = true;
+ private boolean followMode = true;
+ private volatile long lastRefresh;
+
+ public CamelTraceTui(CamelJBangMain main) {
+ super(main);
+ }
+
+ @Override
+ public Integer doCall() throws Exception {
+ // Eagerly load classes used by the input reader thread and picocli
+ // post-processing to avoid ClassNotFoundException during shutdown
+ try {
+ Class.forName("dev.tamboui.tui.event.KeyModifiers");
+ Class.forName("dev.tamboui.tui.event.KeyEvent");
+ Class.forName("dev.tamboui.tui.event.KeyCode");
+ Class.forName("picocli.CommandLine$IExitCodeGenerator");
+ } catch (ClassNotFoundException e) {
+ // ignore
+ }
+
+ // Initial data load
+ refreshData();
+
+ try (var tui = TuiRunner.create()) {
+ sun.misc.Signal.handle(new sun.misc.Signal("INT"), sig -> tui.quit());
+ tui.run(
+ this::handleEvent,
+ this::render);
+ }
+ return 0;
+ }
+
+ // ---- Event Handling ----
+
+ private boolean handleEvent(Event event, TuiRunner runner) {
+ if (event instanceof KeyEvent ke) {
+ if (ke.isQuit() || ke.isCharIgnoreCase('q') || ke.isKey(KeyCode.ESCAPE)) {
+ runner.quit();
+ return true;
+ }
+ if (ke.isUp()) {
+ followMode = false;
+ traceTableState.selectPrevious();
+ return true;
+ }
+ if (ke.isDown()) {
+ List<TraceEntry> current = traces.get();
+ traceTableState.selectNext(current.size());
+ return true;
+ }
+ if (ke.isCharIgnoreCase('h')) {
+ showHeaders = !showHeaders;
+ return true;
+ }
+ if (ke.isCharIgnoreCase('b')) {
+ showBody = !showBody;
+ return true;
+ }
+ if (ke.isCharIgnoreCase('f')) {
+ followMode = !followMode;
+ if (followMode) {
+ List<TraceEntry> current = traces.get();
+ if (!current.isEmpty()) {
+ traceTableState.select(current.size() - 1);
+ }
+ }
+ return true;
+ }
+ }
+ if (event instanceof TickEvent) {
+ long now = System.currentTimeMillis();
+ if (now - lastRefresh >= refreshInterval) {
+ refreshData();
+ }
+ return true;
+ }
+ return false;
+ }
+
+ // ---- Rendering ----
+
+ private void render(Frame frame) {
+ Rect area = frame.area();
+
+ // Layout: header (3 rows) + trace list (50%) + detail panel (50%) + footer (1 row)
+ List<Rect> mainChunks = Layout.vertical()
+ .constraints(
+ Constraint.length(3),
+ Constraint.percentage(50),
+ Constraint.fill(),
+ Constraint.length(1))
+ .split(area);
+
+ renderHeader(frame, mainChunks.get(0));
+ renderTraceList(frame, mainChunks.get(1));
+ renderDetailPanel(frame, mainChunks.get(2));
+ renderFooter(frame, mainChunks.get(3));
+ }
+
+ private void renderHeader(Frame frame, Rect area) {
+ List<TraceEntry> current = traces.get();
+ String filterInfo = grep != null ? " filter: " + grep : "";
+
+ Line titleLine = Line.from(
+ Span.styled(" Camel Trace Viewer", Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)).bold()),
+ Span.raw(" "),
+ Span.styled(current.size() + " trace(s)", Style.create().fg(Color.CYAN)),
+ Span.raw(" "),
+ Span.styled(followMode ? "[FOLLOW]" : "[SCROLL]",
+ Style.create().fg(followMode ? Color.GREEN : Color.YELLOW).bold()),
+ Span.styled(filterInfo, Style.create().dim()));
+
+ Block headerBlock = Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(" Apache Camel Traces ")
+ .build();
+
+ frame.renderWidget(
+ Paragraph.builder().text(Text.from(titleLine)).block(headerBlock).build(),
+ area);
+ }
+
+ private void renderTraceList(Frame frame, Rect area) {
+ List<TraceEntry> current = traces.get();
+
+ // Auto-follow: select last entry
+ if (followMode && !current.isEmpty()) {
+ traceTableState.select(current.size() - 1);
+ }
+
+ List<Row> rows = new ArrayList<>();
+ for (TraceEntry entry : current) {
+ Style statusStyle = switch (entry.status) {
+ case "Created" -> Style.create().fg(Color.CYAN);
+ case "Routing", "Processing" -> Style.create().fg(Color.YELLOW);
+ case "Sent" -> Style.create().fg(Color.GREEN);
+ default -> Style.create().fg(Color.WHITE);
+ };
+
+ String bodyPreview = entry.bodyPreview != null ? truncate(entry.bodyPreview, 40) : "";
+
+ rows.add(Row.from(
+ Cell.from(entry.timestamp != null ? truncate(entry.timestamp, 12) : ""),
+ Cell.from(entry.pid != null ? entry.pid : ""),
+ Cell.from(Span.styled(
+ entry.routeId != null ? truncate(entry.routeId, 15) : "",
+ Style.create().fg(Color.CYAN))),
+ Cell.from(entry.nodeId != null ? truncate(entry.nodeId, 15) : ""),
+ Cell.from(Span.styled(entry.status != null ? entry.status : "", statusStyle)),
+ Cell.from(entry.elapsed + "ms"),
+ Cell.from(bodyPreview)));
+ }
+
+ Row header = Row.from(
+ Cell.from(Span.styled("TIME", Style.create().bold())),
+ Cell.from(Span.styled("PID", Style.create().bold())),
+ Cell.from(Span.styled("ROUTE", Style.create().bold())),
+ Cell.from(Span.styled("NODE", Style.create().bold())),
+ Cell.from(Span.styled("STATUS", Style.create().bold())),
+ Cell.from(Span.styled("ELAPSED", Style.create().bold())),
+ Cell.from(Span.styled("BODY", Style.create().bold())));
+
+ Table table = Table.builder()
+ .rows(rows)
+ .header(header)
+ .widths(
+ Constraint.length(12),
+ Constraint.length(8),
+ Constraint.length(15),
+ Constraint.length(15),
+ Constraint.length(12),
+ Constraint.length(10),
+ Constraint.fill())
+ .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue())
+ .block(Block.builder().borderType(BorderType.ROUNDED).title(" Traces ").build())
+ .build();
+
+ frame.renderStatefulWidget(table, area, traceTableState);
+ }
+
+ private void renderDetailPanel(Frame frame, Rect area) {
+ List<TraceEntry> current = traces.get();
+ Integer sel = traceTableState.selected();
+
+ if (sel == null || sel < 0 || sel >= current.size()) {
+ frame.renderWidget(
+ Paragraph.builder()
+ .text(Text.from(Line.from(
+ Span.styled(" Select a trace entry to view details",
+ Style.create().dim()))))
+ .block(Block.builder().borderType(BorderType.ROUNDED)
+ .title(" Detail ").build())
+ .build(),
+ area);
+ return;
+ }
+
+ TraceEntry entry = current.get(sel);
+ List<Line> lines = new ArrayList<>();
+
+ // Exchange info
+ lines.add(Line.from(
+ Span.styled(" Exchange: ", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(entry.exchangeId != null ? entry.exchangeId : "")));
+ lines.add(Line.from(
+ Span.styled(" UID: ", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(entry.uid != null ? entry.uid : "")));
+ lines.add(Line.from(
+ Span.styled(" Location: ", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(entry.location != null ? entry.location : "")));
+ lines.add(Line.from(
+ Span.styled(" Route: ", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(entry.routeId != null ? entry.routeId : ""),
+ Span.raw(" Node: "),
+ Span.raw(entry.nodeId != null ? entry.nodeId : "")));
+ lines.add(Line.from(
+ Span.styled(" Status: ", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(entry.status != null ? entry.status : ""),
+ Span.raw(" Elapsed: "),
+ Span.raw(entry.elapsed + "ms")));
+ lines.add(Line.from(Span.raw("")));
+
+ // Headers
+ if (showHeaders && entry.headers != null && !entry.headers.isEmpty()) {
+ lines.add(Line.from(Span.styled(" Headers:", Style.create().fg(Color.GREEN).bold())));
+ for (Map.Entry<String, Object> h : entry.headers.entrySet()) {
+ lines.add(Line.from(
+ Span.styled(" " + h.getKey(), Style.create().fg(Color.CYAN)),
+ Span.raw(" = "),
+ Span.raw(h.getValue() != null ? h.getValue().toString() : "null")));
+ }
+ lines.add(Line.from(Span.raw("")));
+ }
+
+ // Body
+ if (showBody && entry.body != null) {
+ lines.add(Line.from(Span.styled(" Body:", Style.create().fg(Color.GREEN).bold())));
+ // Split body into lines for display
+ String[] bodyLines = entry.body.split("\n");
+ for (String bl : bodyLines) {
+ lines.add(Line.from(Span.raw(" " + bl)));
+ }
+ lines.add(Line.from(Span.raw("")));
+ }
+
+ // Exchange properties
+ if (entry.exchangeProperties != null && !entry.exchangeProperties.isEmpty()) {
+ lines.add(Line.from(Span.styled(" Exchange Properties:", Style.create().fg(Color.GREEN).bold())));
+ for (Map.Entry<String, Object> p : entry.exchangeProperties.entrySet()) {
+ lines.add(Line.from(
+ Span.styled(" " + p.getKey(), Style.create().fg(Color.CYAN)),
+ Span.raw(" = "),
+ Span.raw(p.getValue() != null ? p.getValue().toString() : "null")));
+ }
+ lines.add(Line.from(Span.raw("")));
+ }
+
+ // Exchange variables
+ if (entry.exchangeVariables != null && !entry.exchangeVariables.isEmpty()) {
+ lines.add(Line.from(Span.styled(" Exchange Variables:", Style.create().fg(Color.GREEN).bold())));
+ for (Map.Entry<String, Object> v : entry.exchangeVariables.entrySet()) {
+ lines.add(Line.from(
+ Span.styled(" " + v.getKey(), Style.create().fg(Color.CYAN)),
+ Span.raw(" = "),
+ Span.raw(v.getValue() != null ? v.getValue().toString() : "null")));
+ }
+ }
+
+ String title = String.format(" Detail [%s] ", entry.exchangeId != null ? truncate(entry.exchangeId, 30) : "");
+
+ Paragraph detail = Paragraph.builder()
+ .text(Text.from(lines))
+ .overflow(Overflow.CLIP)
+ .block(Block.builder().borderType(BorderType.ROUNDED).title(title).build())
+ .build();
+
+ frame.renderWidget(detail, area);
+ }
+
+ private void renderFooter(Frame frame, Rect area) {
+ Line footer = Line.from(
+ Span.styled(" q", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" quit "),
+ Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" navigate "),
+ Span.styled("h", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" headers" + (showHeaders ? " [on]" : " [off]") + " "),
+ Span.styled("b", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" body" + (showBody ? " [on]" : " [off]") + " "),
+ Span.styled("f", Style.create().fg(Color.YELLOW).bold()),
+ Span.raw(" follow" + (followMode ? " [on]" : " [off]") + " "),
+ Span.styled("Refresh: " + refreshInterval + "ms", Style.create().dim()));
+
+ frame.renderWidget(Paragraph.from(footer), area);
+ }
+
+ // ---- Data Loading ----
+
+ private void refreshData() {
+ lastRefresh = System.currentTimeMillis();
+ try {
+ List<Long> pids = findPids(name);
+ List<TraceEntry> allTraces = new ArrayList<>(traces.get());
+
+ for (Long pid : pids) {
+ readTraceFile(Long.toString(pid), allTraces);
+ }
+
+ // Sort by timestamp
+ allTraces.sort((a, b) -> {
+ if (a.timestamp == null && b.timestamp == null) {
+ return 0;
+ }
+ if (a.timestamp == null) {
+ return -1;
+ }
+ if (b.timestamp == null) {
+ return 1;
+ }
+ return a.timestamp.compareTo(b.timestamp);
+ });
+
+ // Keep only last MAX_TRACES
+ if (allTraces.size() > MAX_TRACES) {
+ allTraces = new ArrayList<>(allTraces.subList(allTraces.size() - MAX_TRACES, allTraces.size()));
+ }
+
+ // Apply grep filter
+ if (grep != null && !grep.isEmpty()) {
+ String lowerGrep = grep.toLowerCase();
+ allTraces = allTraces.stream()
+ .filter(t -> matchesGrep(t, lowerGrep))
+ .collect(java.util.stream.Collectors.toList());
+ }
+
+ traces.set(allTraces);
+ } catch (Exception e) {
+ // ignore refresh errors
+ }
+ }
+
+ private boolean matchesGrep(TraceEntry entry, String lowerGrep) {
+ if (entry.exchangeId != null && entry.exchangeId.toLowerCase().contains(lowerGrep)) {
+ return true;
+ }
+ if (entry.routeId != null && entry.routeId.toLowerCase().contains(lowerGrep)) {
+ return true;
+ }
+ if (entry.nodeId != null && entry.nodeId.toLowerCase().contains(lowerGrep)) {
+ return true;
+ }
+ if (entry.status != null && entry.status.toLowerCase().contains(lowerGrep)) {
+ return true;
+ }
+ if (entry.body != null && entry.body.toLowerCase().contains(lowerGrep)) {
+ return true;
+ }
+ if (entry.location != null && entry.location.toLowerCase().contains(lowerGrep)) {
+ return true;
+ }
+ return false;
+ }
+
+ @SuppressWarnings("unchecked")
+ private void readTraceFile(String pid, List<TraceEntry> allTraces) {
+ Path traceFile = CommandLineHelper.getCamelDir().resolve(pid + "-trace.json");
+ if (!Files.exists(traceFile)) {
+ return;
+ }
+
+ long lastPos = traceFilePositions.getOrDefault(pid, 0L);
+
+ try (RandomAccessFile raf = new RandomAccessFile(traceFile.toFile(), "r")) {
+ long length = raf.length();
+ if (length <= lastPos) {
+ return; // no new data
+ }
+
+ raf.seek(lastPos);
+ // If we're resuming mid-file, skip any partial line
+ if (lastPos > 0) {
+ raf.readLine();
+ }
+
+ // Read remaining bytes
+ long startPos = raf.getFilePointer();
+ byte[] remaining = new byte[(int) (length - startPos)];
+ raf.readFully(remaining);
+ String content = new String(remaining, StandardCharsets.UTF_8);
+
+ traceFilePositions.put(pid, length);
+
+ // Each line is a separate JSON object
+ String[] lines = content.split("\n");
+ for (String line : lines) {
+ line = line.trim();
+ if (line.isEmpty()) {
+ continue;
+ }
+ try {
+ JsonObject json = (JsonObject) Jsoner.deserialize(line);
+ TraceEntry entry = parseTraceEntry(json, pid);
+ if (entry != null) {
+ allTraces.add(entry);
+ }
+ } catch (Exception e) {
+ // skip malformed lines
+ }
+ }
+ } catch (IOException e) {
+ // ignore
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private TraceEntry parseTraceEntry(JsonObject json, String pid) {
+ TraceEntry entry = new TraceEntry();
+ entry.pid = pid;
+ entry.uid = json.getString("uid");
+ entry.exchangeId = json.getString("exchangeId");
+ entry.timestamp = json.getString("timestamp");
+ entry.routeId = json.getString("routeId");
+ entry.nodeId = json.getString("nodeId");
+ entry.location = json.getString("location");
+ entry.status = json.getString("status");
+
+ Object elapsedObj = json.get("elapsed");
+ if (elapsedObj instanceof Number n) {
+ entry.elapsed = n.longValue();
+ } else if (elapsedObj != null) {
+ try {
+ entry.elapsed = Long.parseLong(elapsedObj.toString());
+ } catch (NumberFormatException e) {
+ // ignore
+ }
+ }
+
+ // Parse message object
+ JsonObject message = (JsonObject) json.get("message");
+ if (message != null) {
+ // Headers
+ Object headersObj = message.get("headers");
+ if (headersObj instanceof Map) {
+ entry.headers = new LinkedHashMap<>((Map<String, Object>) headersObj);
+ }
+
+ // Body
+ Object bodyObj = message.get("body");
+ if (bodyObj != null) {
+ entry.body = bodyObj.toString();
+ // Create a preview (first line, truncated)
+ String preview = entry.body.replace("\n", " ").replace("\r", "");
+ entry.bodyPreview = preview;
+ }
+
+ // Exchange properties
+ Object propsObj = message.get("exchangeProperties");
+ if (propsObj instanceof Map) {
+ entry.exchangeProperties = new LinkedHashMap<>((Map<String, Object>) propsObj);
+ }
+
+ // Exchange variables
+ Object varsObj = message.get("exchangeVariables");
+ if (varsObj instanceof Map) {
+ entry.exchangeVariables = new LinkedHashMap<>((Map<String, Object>) varsObj);
+ }
+ }
+
+ return entry;
+ }
+
+ // ---- Helpers ----
+
+ private List<Long> findPids(String name) {
+ List<Long> pids = new ArrayList<>();
+ final long cur = ProcessHandle.current().pid();
+ String pattern = name;
+ if (!pattern.matches("\\d+") && !pattern.endsWith("*")) {
+ pattern = pattern + "*";
+ }
+ final String pat = pattern;
+ ProcessHandle.allProcesses()
+ .filter(ph -> ph.pid() != cur)
+ .forEach(ph -> {
+ JsonObject root = loadStatus(ph.pid());
+ if (root != null) {
+ String pName = ProcessHelper.extractName(root, ph);
+ pName = FileUtil.onlyName(pName);
+ if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) {
+ pids.add(ph.pid());
+ } else {
+ JsonObject context = (JsonObject) root.get("context");
+ if (context != null) {
+ pName = context.getString("name");
+ if ("CamelJBang".equals(pName)) {
+ pName = null;
+ }
+ if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) {
+ pids.add(ph.pid());
+ }
+ }
+ }
+ }
+ });
+ return pids;
+ }
+
+ private JsonObject loadStatus(long pid) {
+ try {
+ Path f = getStatusFile(Long.toString(pid));
+ if (f != null && Files.exists(f)) {
+ String text = Files.readString(f);
+ return (JsonObject) Jsoner.deserialize(text);
+ }
+ } catch (Exception e) {
+ // ignore
+ }
+ return null;
+ }
+
+ private static String truncate(String s, int max) {
+ if (s == null) {
+ return "";
+ }
+ return s.length() > max ? s.substring(0, max - 1) + "\u2026" : s;
+ }
+
+ // ---- Data Classes ----
+
+ static class TraceEntry {
+ String pid;
+ String uid;
+ String exchangeId;
+ String timestamp;
+ String routeId;
+ String nodeId;
+ String location;
+ String status;
+ long elapsed;
+ String body;
+ String bodyPreview;
+ Map<String, Object> headers;
+ Map<String, Object> exchangeProperties;
+ Map<String, Object> exchangeVariables;
+ }
+}
diff --git a/parent/pom.xml b/parent/pom.xml
index 891f9e99f8af..050a06d85223 100644
--- a/parent/pom.xml
+++ b/parent/pom.xml
@@ -327,7 +327,7 @@
<jgroups-raft-leveldbjni-version>1.8</jgroups-raft-leveldbjni-version>
<jgroups-raft-mapdb-version>1.0.8</jgroups-raft-mapdb-version>
<jira-rest-client-api-version>6.0.2</jira-rest-client-api-version>
- <jline-version>4.0.7</jline-version>
+ <jline-version>4.0.9</jline-version>
<libthrift-version>0.22.0</libthrift-version>
<jodatime2-version>2.14.1</jodatime2-version>
<jolokia-version>2.5.1</jolokia-version>