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:10 UTC
(camel) branch Guillaume-Nodet/camel-tui created (now df9143e67174)
This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a change to branch Guillaume-Nodet/camel-tui
in repository https://gitbox.apache.org/repos/asf/camel.git
at df9143e67174 CAMEL-23226: Add TUI dashboards for Camel JBang
This branch includes the following new commits:
new df9143e67174 CAMEL-23226: Add TUI dashboards for Camel JBang
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(camel) 01/01: CAMEL-23226: Add TUI dashboards for Camel JBang
Posted by gn...@apache.org.
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>