You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/06/01 18:35:11 UTC
(camel) branch main updated: CAMEL-23656: Add route topology service and TUI diagram tab (#23680)
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 06233633793d CAMEL-23656: Add route topology service and TUI diagram tab (#23680)
06233633793d is described below
commit 06233633793d9391c48de6f66504b47444e07397
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Mon Jun 1 20:35:05 2026 +0200
CAMEL-23656: Add route topology service and TUI diagram tab (#23680)
* CAMEL-23648: camel-jbang - TUI dedicated Diagram tab with topology view
Add a new Diagram tab (key 3) that shows inter-route topology by default.
The topology auto-loads as text diagram when the tab is selected, with
metrics enabled. Shift Routes to key 4 and move Metrics into the More
dropdown. Fix consistent box heights in topology renderer by reserving
2 lines for from text and always adding a metrics line.
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
* CAMEL-23648: camel-jbang - TUI use text-only diagrams, remove PNG image mode
Simplify both Diagram and Routes tabs to text-only rendering. This
removes flickering from PNG re-rendering during metrics refresh and
better fits the TUI paradigm.
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
* CAMEL-23648: camel-jbang - TUI add description toggle and footer fixes
Add description toggle (n key) for both topology and route diagrams.
Standardize footer hint wording to top/end and fix spacing between hints.
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
* CAMEL-23648: camel-jbang - TUI use text-only diagram in Inspect tab
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <no...@anthropic.com>
---
.../camel/diagram/TopologyAsciiRenderer.java | 57 +++--
.../apache/camel/diagram/TopologyLayoutEngine.java | 4 +-
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 83 ++++---
.../jbang/core/commands/tui/DiagramSupport.java | 129 ++++++++++-
.../dsl/jbang/core/commands/tui/DiagramTab.java | 256 +++++++++++++++++++++
.../dsl/jbang/core/commands/tui/HistoryTab.java | 9 +-
.../dsl/jbang/core/commands/tui/RoutesTab.java | 22 +-
.../dsl/jbang/core/commands/tui/StartupTab.java | 2 +-
8 files changed, 480 insertions(+), 82 deletions(-)
diff --git a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java
index cccda5f20544..25c6f23dcd4b 100644
--- a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java
+++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java
@@ -52,14 +52,14 @@ public class TopologyAsciiRenderer {
private final boolean showDescription;
private final List<CounterPos> counterPositions = new ArrayList<>();
- enum CounterType {
+ public enum CounterType {
OK,
FAIL,
TRIGGER,
EXTERNAL
}
- record CounterPos(int row, int col, int length, CounterType type) {
+ public record CounterPos(int row, int col, int length, CounterType type) {
}
public TopologyAsciiRenderer(int nodeWidth, boolean unicode) {
@@ -78,7 +78,16 @@ public class TopologyAsciiRenderer {
return boxWidth;
}
+ public List<CounterPos> getCounterPositions() {
+ return counterPositions;
+ }
+
public String renderDiagram(TopologyLayoutResult result) {
+ String plain = renderDiagramPlain(result);
+ return applyAnsiColors(plain);
+ }
+
+ public String renderDiagramPlain(TopologyLayoutResult result) {
counterPositions.clear();
int gridWidth = toCol(result.totalWidth) + boxWidth + 4;
@@ -110,8 +119,7 @@ public class TopologyAsciiRenderer {
drawNode(grid, node);
}
- String plain = gridToString(grid);
- return applyAnsiColors(plain);
+ return gridToString(grid);
}
private void drawNode(char[][] grid, TopologyLayoutNode node) {
@@ -129,22 +137,31 @@ public class TopologyAsciiRenderer {
lines.addAll(wrapText(line1, boxWidth - 4));
if (!showDescription) {
String line2 = "(" + node.from + ")";
- lines.addAll(wrapText(line2, boxWidth - 4));
+ List<String> fromLines = wrapText(line2, boxWidth - 4);
+ lines.addAll(fromLines);
+ // Always reserve 2 lines for from so all boxes have the same height
+ if (fromLines.size() < 2) {
+ lines.add("");
+ }
}
- if (metrics && (node.exchangesTotal > 0 || node.exchangesFailed > 0)) {
- long ok = node.exchangesTotal - node.exchangesFailed;
- StringBuilder sb = new StringBuilder();
- if (ok > 0) {
- sb.append(ok);
- }
- if (node.exchangesFailed > 0) {
- if (!sb.isEmpty()) {
- sb.append("/");
+ if (metrics) {
+ if (node.exchangesTotal > 0 || node.exchangesFailed > 0) {
+ long ok = node.exchangesTotal - node.exchangesFailed;
+ StringBuilder sb = new StringBuilder();
+ if (ok > 0) {
+ sb.append(ok);
}
- sb.append(node.exchangesFailed).append("!");
+ if (node.exchangesFailed > 0) {
+ if (!sb.isEmpty()) {
+ sb.append("/");
+ }
+ sb.append(node.exchangesFailed).append("!");
+ }
+ lines.add(sb.toString());
+ } else {
+ lines.add("");
}
- lines.add(sb.toString());
}
// Trim to MAX_WRAP_LINES
@@ -191,10 +208,6 @@ public class TopologyAsciiRenderer {
int textCol = col + 2 + Math.max(0, (innerWidth - text.length()) / 2);
drawText(grid, r, textCol, text);
- // Record ANSI color for trigger nodes and metrics
- if (i == 0 && "trigger".equals(node.nodeType)) {
- counterPositions.add(new CounterPos(r, textCol, text.length(), CounterType.TRIGGER));
- }
if (metrics && i == lines.size() - 1 && node.exchangesTotal > 0) {
long ok = node.exchangesTotal - node.exchangesFailed;
if (ok > 0) {
@@ -278,8 +291,8 @@ public class TopologyAsciiRenderer {
}
private int boxHeight(TopologyLayoutNode node) {
- int lines = 2; // routeId + from
- if (metrics && node.exchangesTotal > 0) {
+ int lines = 3; // routeId + from (2 lines reserved)
+ if (metrics) {
lines++;
}
return 2 + Math.min(lines, MAX_WRAP_LINES + 1);
diff --git a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyLayoutEngine.java b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyLayoutEngine.java
index 4fba5a9aa22d..b00026da41f0 100644
--- a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyLayoutEngine.java
+++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyLayoutEngine.java
@@ -34,9 +34,9 @@ public class TopologyLayoutEngine {
static final int V_GAP = 50 * SCALE;
static final int H_GAP = 30 * SCALE;
static final int PADDING = RouteDiagramLayoutEngine.PADDING;
- static final int DEFAULT_NODE_WIDTH = 180;
+ public static final int DEFAULT_NODE_WIDTH = 180;
static final int DEFAULT_NODE_HEIGHT = 40;
- static final int DEFAULT_FONT_SIZE = 12;
+ public static final int DEFAULT_FONT_SIZE = 12;
private final int nodeWidth;
private final int nodeHeight;
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index 5db7e2fa6d69..e22a456156f0 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -107,13 +107,13 @@ public class CamelMonitor extends CamelCommand {
// Tab indices
private static final int TAB_OVERVIEW = 0;
private static final int TAB_LOG = 1;
- private static final int TAB_ROUTES = 2;
- private static final int TAB_ENDPOINTS = 3;
- private static final int TAB_HTTP = 4;
- private static final int TAB_HEALTH = 5;
- private static final int TAB_HISTORY = 6;
- private static final int TAB_ERRORS = 7;
- private static final int TAB_METRICS = 8;
+ private static final int TAB_DIAGRAM = 2;
+ private static final int TAB_ROUTES = 3;
+ private static final int TAB_ENDPOINTS = 4;
+ private static final int TAB_HTTP = 5;
+ private static final int TAB_HEALTH = 6;
+ private static final int TAB_HISTORY = 7;
+ private static final int TAB_ERRORS = 8;
private static final int TAB_MORE = 9;
@CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1")
@@ -250,6 +250,7 @@ public class CamelMonitor extends CamelCommand {
private MonitorContext ctx;
private LogTab logTab;
+ private DiagramTab diagramTab;
private RoutesTab routesTab;
private ConsumersTab consumersTab;
private EndpointsTab endpointsTab;
@@ -314,6 +315,7 @@ public class CamelMonitor extends CamelCommand {
actionsPopup.setContext(ctx);
actionsPopup.setResetStatsAction(this::resetStats);
logTab = new LogTab(ctx);
+ diagramTab = new DiagramTab(ctx);
routesTab = new RoutesTab(ctx);
consumersTab = new ConsumersTab(ctx);
endpointsTab = new EndpointsTab(
@@ -437,7 +439,7 @@ public class CamelMonitor extends CamelCommand {
return true;
}
if (ke.isDown()) {
- morePopupState.selectNext(10);
+ morePopupState.selectNext(11);
return true;
}
// Shortcut keys for quick selection
@@ -459,8 +461,9 @@ public class CamelMonitor extends CamelCommand {
case 5 -> consumersTab;
case 6 -> inflightTab;
case 7 -> memoryTab;
- case 8 -> startupTab;
- case 9 -> threadsTab;
+ case 8 -> metricsTab;
+ case 9 -> startupTab;
+ case 10 -> threadsTab;
default -> null;
};
if (activeMoreTab != null) {
@@ -555,25 +558,25 @@ public class CamelMonitor extends CamelCommand {
}
if (!isInfraSelected()) {
if (ke.isChar('3')) {
- return handleTabKey(TAB_ROUTES);
+ return handleTabKey(TAB_DIAGRAM);
}
if (ke.isChar('4')) {
- return handleTabKey(TAB_ENDPOINTS);
+ return handleTabKey(TAB_ROUTES);
}
if (ke.isChar('5')) {
- return handleTabKey(TAB_HTTP);
+ return handleTabKey(TAB_ENDPOINTS);
}
if (ke.isChar('6')) {
- return handleTabKey(TAB_HEALTH);
+ return handleTabKey(TAB_HTTP);
}
if (ke.isChar('7')) {
- return handleTabKey(TAB_HISTORY);
+ return handleTabKey(TAB_HEALTH);
}
if (ke.isChar('8')) {
- return handleTabKey(TAB_ERRORS);
+ return handleTabKey(TAB_HISTORY);
}
if (ke.isChar('9')) {
- return handleTabKey(TAB_METRICS);
+ return handleTabKey(TAB_ERRORS);
}
if (ke.isChar('0')) {
return handleTabKey(TAB_MORE);
@@ -783,13 +786,15 @@ public class CamelMonitor extends CamelCommand {
long cutoff = now - 2000;
recentKeys.removeIf(k -> k.timestamp() < cutoff);
}
- long interval = routesTab.isShowDiagram() ? Math.max(refreshInterval, 1000) : refreshInterval;
+ boolean anyDiagramShowing = routesTab.isShowDiagram() || diagramTab.isShowDiagram();
+ long interval = anyDiagramShowing ? Math.max(refreshInterval, 1000) : refreshInterval;
if (now - lastRefresh >= interval) {
refreshData();
routesTab.refreshDiagramIfNeeded();
+ diagramTab.refreshDiagramIfNeeded();
return true;
}
- return !routesTab.hasImageDiagram();
+ return true;
}
return false;
}
@@ -861,6 +866,9 @@ public class CamelMonitor extends CamelCommand {
refreshLogData();
logTab.onTabSelected();
}
+ if (tab == TAB_DIAGRAM) {
+ diagramTab.onTabSelected();
+ }
if (tab == TAB_HISTORY && ctx.selectedPid != null) {
try {
long pid = Long.parseLong(ctx.selectedPid);
@@ -904,6 +912,7 @@ public class CamelMonitor extends CamelCommand {
}
private void resetIntegrationTabState() {
+ diagramTab.onIntegrationChanged();
routesTab.onIntegrationChanged();
httpTab.onIntegrationChanged();
logTab.onIntegrationChanged();
@@ -1064,19 +1073,17 @@ public class CamelMonitor extends CamelCommand {
boolean hasTraces = hasSelection && !traces.get().isEmpty();
int httpCount = hasSelection ? sel.httpEndpoints.size() : 0;
- int metricsCount = hasSelection ? sel.meters.size() : 0;
-
// Row 0: label-only titles — fixed width so the tab bar never shifts when badges appear
Line[] labels = {
Line.from(" 1 Overview "),
Line.from(" 2 Log "),
- Line.from(routesTab.isTopMode() ? " 3 Top " : " 3 Route "),
- Line.from(" 4 Endpoint "),
- Line.from(" 5 HTTP "),
- Line.from(" 6 Health "),
- Line.from(" 7 Inspect "),
- Line.from(" 8 Errors "),
- Line.from(" 9 Metrics "),
+ Line.from(" 3 Diagram "),
+ Line.from(routesTab.isTopMode() ? " 4 Top " : " 4 Route "),
+ Line.from(" 5 Endpoint "),
+ Line.from(" 6 HTTP "),
+ Line.from(" 7 Health "),
+ Line.from(" 8 Inspect "),
+ Line.from(" 9 Errors "),
Line.from(" 0 More▾ "),
};
currentTabLabels = labels;
@@ -1111,6 +1118,7 @@ public class CamelMonitor extends CamelCommand {
badgeTexts[TAB_OVERVIEW] = "(" + activeCount + ")";
}
if (routeCount > 0) {
+ badgeTexts[TAB_DIAGRAM] = "(1)";
badgeTexts[TAB_ROUTES] = "(" + routeCount + ")";
}
if (endpointCount > 0) {
@@ -1131,9 +1139,6 @@ public class CamelMonitor extends CamelCommand {
} else if (historyCount > 0) {
badgeTexts[TAB_HISTORY] = "(" + historyCount + ")";
}
- if (metricsCount > 0) {
- badgeTexts[TAB_METRICS] = "(" + metricsCount + ")";
- }
if (cbOpenCount > 0) {
badgeTexts[TAB_MORE] = "(" + cbOpenCount + " OPEN)";
badgeStyles[TAB_MORE] = red;
@@ -1179,7 +1184,7 @@ public class CamelMonitor extends CamelCommand {
private void renderMorePopup(Frame frame, Rect area) {
int popupW = 22;
- int popupH = 11;
+ int popupH = 12;
// Position just below the "0 More▾" tab label
int dividerW = CharWidth.of(" | ");
int tabBarX = 0;
@@ -1209,6 +1214,7 @@ public class CamelMonitor extends CamelCommand {
ListItem.from(Line.from(Span.raw(" Co"), Span.styled("n", keyStyle), Span.raw("sumers"))),
ListItem.from(Line.from(Span.raw(" "), Span.styled("I", keyStyle), Span.raw("nflight"))),
ListItem.from(Line.from(Span.raw(" "), Span.styled("M", keyStyle), Span.raw("emory"))),
+ ListItem.from(Line.from(Span.raw(" M"), Span.styled("e", keyStyle), Span.raw("trics"))),
ListItem.from(Line.from(Span.raw(" "), Span.styled("S", keyStyle), Span.raw("tartup"))),
ListItem.from(Line.from(Span.raw(" "), Span.styled("T", keyStyle), Span.raw("hreads"))),
};
@@ -1305,12 +1311,15 @@ public class CamelMonitor extends CamelCommand {
if (ke.isChar('m')) {
return 7;
}
- if (ke.isChar('s')) {
+ if (ke.isChar('e')) {
return 8;
}
- if (ke.isChar('t')) {
+ if (ke.isChar('s')) {
return 9;
}
+ if (ke.isChar('t')) {
+ return 10;
+ }
return -1;
}
@@ -1318,13 +1327,13 @@ public class CamelMonitor extends CamelCommand {
return switch (tabsState.selected()) {
case TAB_OVERVIEW -> overviewTab;
case TAB_LOG -> logTab;
+ case TAB_DIAGRAM -> diagramTab;
case TAB_ROUTES -> routesTab;
case TAB_ENDPOINTS -> endpointsTab;
case TAB_HEALTH -> healthTab;
case TAB_HISTORY -> historyTab;
case TAB_HTTP -> httpTab;
case TAB_ERRORS -> errorsTab;
- case TAB_METRICS -> metricsTab;
case TAB_MORE -> activeMoreTab;
default -> null;
};
@@ -2571,8 +2580,8 @@ public class CamelMonitor extends CamelCommand {
// ---- MCP accessor methods ----
private static final String[] TAB_NAMES = {
- "Overview", "Log", "Routes", "Endpoints",
- "HTTP", "Health", "Inspect", "Errors", "Circuit Breaker", "Consumers"
+ "Overview", "Log", "Diagram", "Routes", "Endpoints",
+ "HTTP", "Health", "Inspect", "Errors", "More"
};
Buffer getLastBuffer() {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
index ba3dddb74a0c..7d67c3475565 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
@@ -50,6 +50,13 @@ import org.apache.camel.diagram.RouteDiagramAsciiRenderer;
import org.apache.camel.diagram.RouteDiagramHelper;
import org.apache.camel.diagram.RouteDiagramLayoutEngine;
import org.apache.camel.diagram.RouteDiagramRenderer;
+import org.apache.camel.diagram.TopologyAsciiRenderer;
+import org.apache.camel.diagram.TopologyHelper;
+import org.apache.camel.diagram.TopologyImageRenderer;
+import org.apache.camel.diagram.TopologyLayoutEngine;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyEdgeInfo;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutResult;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyNodeInfo;
import org.apache.camel.dsl.jbang.core.common.PathUtils;
import org.apache.camel.util.json.JsonObject;
@@ -59,6 +66,8 @@ class DiagramSupport {
private boolean showDiagram;
private boolean diagramTextMode;
+ private boolean topologyMode;
+ private boolean showDescription;
private List<RouteDiagramAsciiRenderer.CounterPos> counterPositions = Collections.emptyList();
private Set<Integer> routeTitleRows = Collections.emptySet();
private List<String> lines = Collections.emptyList();
@@ -83,6 +92,22 @@ class DiagramSupport {
return diagramTextMode;
}
+ boolean isTopologyMode() {
+ return topologyMode;
+ }
+
+ void setTopologyMode(boolean topologyMode) {
+ this.topologyMode = topologyMode;
+ }
+
+ boolean isShowDescription() {
+ return showDescription;
+ }
+
+ void setShowDescription(boolean showDescription) {
+ this.showDescription = showDescription;
+ }
+
boolean hasDiagramData() {
return diagramTextMode ? !lines.isEmpty() : fullImageData != null;
}
@@ -149,6 +174,16 @@ class DiagramSupport {
}
}
+ void switchToImageMode() {
+ diagramTextMode = false;
+ showDiagram = true;
+ }
+
+ void switchToTextMode() {
+ diagramTextMode = true;
+ showDiagram = true;
+ }
+
boolean handleEscape() {
if (showDiagram) {
close();
@@ -175,7 +210,7 @@ class DiagramSupport {
hint(spans, closeKey + "/Esc", "close");
hint(spans, "↑↓←→", "scroll");
hint(spans, "PgUp/PgDn", "page");
- hintLast(spans, "Home/End", "top/bottom");
+ hint(spans, "Home/End", "top/end");
}
// ---- Rendering ----
@@ -395,14 +430,104 @@ class DiagramSupport {
renderRoutes(ctx, textMode, routes, metrics, null, null);
}
+ void loadTopologyDiagramInBackground(
+ MonitorContext ctx, String pid, boolean textMode, boolean metrics) {
+ JsonObject jo = requestRouteTopology(ctx, pid);
+ if (jo == null) {
+ applyResult(ctx, List.of("(No response from integration)"), null, null, null);
+ return;
+ }
+
+ List<TopologyNodeInfo> nodes = TopologyHelper.parseNodes(jo);
+ List<TopologyEdgeInfo> edges = TopologyHelper.parseEdges(jo);
+ if (nodes.isEmpty()) {
+ applyResult(ctx, List.of("(No routes in response)"), null, null, null);
+ return;
+ }
+
+ TopologyLayoutEngine engine = new TopologyLayoutEngine();
+ TopologyLayoutResult result = engine.layout(nodes, edges);
+
+ if (textMode) {
+ TopologyAsciiRenderer renderer = new TopologyAsciiRenderer(
+ engine.getNodeWidth(), true, metrics, showDescription);
+ String text = renderer.renderDiagramPlain(result);
+
+ List<String> resultLines = new ArrayList<>();
+ List<RouteDiagramAsciiRenderer.CounterPos> positions = new ArrayList<>();
+ String[] rawLines = text.split("\n", -1);
+ int[] rowMapping = new int[rawLines.length];
+ int newRow = 0;
+ for (int i = 0; i < rawLines.length; i++) {
+ if (!rawLines[i].isEmpty()) {
+ rowMapping[i] = newRow++;
+ resultLines.add(rawLines[i]);
+ } else {
+ rowMapping[i] = -1;
+ }
+ }
+
+ for (TopologyAsciiRenderer.CounterPos cp : renderer.getCounterPositions()) {
+ if (cp.row() >= 0 && cp.row() < rowMapping.length && rowMapping[cp.row()] >= 0) {
+ RouteDiagramAsciiRenderer.CounterType mapped = switch (cp.type()) {
+ case OK -> RouteDiagramAsciiRenderer.CounterType.OK;
+ case FAIL -> RouteDiagramAsciiRenderer.CounterType.FAIL;
+ case TRIGGER -> RouteDiagramAsciiRenderer.CounterType.HIGHLIGHT_SUCCESS;
+ case EXTERNAL -> RouteDiagramAsciiRenderer.CounterType.OK;
+ };
+ positions.add(new RouteDiagramAsciiRenderer.CounterPos(
+ rowMapping[cp.row()], cp.col(), cp.length(), mapped));
+ }
+ }
+
+ applyResult(ctx, resultLines, null, null, null, positions, Collections.emptySet());
+ } else {
+ TerminalImageCapabilities caps = TerminalImageCapabilities.detect();
+ if (caps.supportsNativeImages()) {
+ RouteDiagramRenderer.DiagramColors colors
+ = RouteDiagramRenderer.DiagramColors.parse("transparent");
+ java.awt.image.BufferedImage image = TopologyImageRenderer.renderImage(
+ result, colors, TopologyLayoutEngine.DEFAULT_FONT_SIZE,
+ TopologyLayoutEngine.DEFAULT_NODE_WIDTH, metrics, false);
+ ImageData full = ImageData.fromBufferedImage(image);
+ ImageData resized = full.resize(full.width() / 2, full.height() / 2);
+ ImageProtocol proto = caps.bestProtocol();
+ applyResult(ctx, Collections.emptyList(), resized, resized, proto);
+ } else {
+ applyResult(ctx, List.of(
+ "(Terminal does not support image rendering)",
+ "(Press Shift+D for text diagram)"), null, null, null);
+ }
+ }
+ }
+
+ private JsonObject requestRouteTopology(MonitorContext ctx, String pid) {
+ Path outputFile = ctx.getOutputFile(pid);
+ PathUtils.deleteFile(outputFile);
+
+ JsonObject root = new JsonObject();
+ root.put("action", "route-topology");
+ root.put("metric", "true");
+
+ Path actionFile = ctx.getActionFile(pid);
+ PathUtils.writeTextSafely(root.toJson(), actionFile);
+
+ JsonObject jo = pollJsonResponse(outputFile, 5000);
+ PathUtils.deleteFile(outputFile);
+ return jo;
+ }
+
private void renderRoutes(
MonitorContext ctx, boolean textMode,
List<RouteDiagramLayoutEngine.RouteInfo> routes, boolean metrics,
Set<String> highlightNodeIds, RouteDiagramHelper.HighlightStyle hlStyle) {
if (textMode) {
+ RouteDiagramLayoutEngine.NodeLabelMode labelMode = showDescription
+ ? RouteDiagramLayoutEngine.NodeLabelMode.DESCRIPTION
+ : RouteDiagramLayoutEngine.NodeLabelMode.CODE;
RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(
RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH, RouteDiagramLayoutEngine.DEFAULT_FONT_SIZE,
- RouteDiagramLayoutEngine.NodeLabelMode.CODE);
+ labelMode);
List<String> result = new ArrayList<>();
List<RouteDiagramAsciiRenderer.CounterPos> positions = new ArrayList<>();
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
new file mode 100644
index 000000000000..0311dbad42ef
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
@@ -0,0 +1,256 @@
+/*
+ * 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.List;
+
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.text.Text;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.paragraph.Paragraph;
+
+import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.*;
+
+class DiagramTab implements MonitorTab {
+
+ private final MonitorContext ctx;
+ private final DiagramSupport diagram = new DiagramSupport();
+ private boolean diagramMetrics = true;
+ private boolean topologyMode = true;
+ private String drillDownRouteId;
+
+ DiagramTab(MonitorContext ctx) {
+ this.ctx = ctx;
+ }
+
+ boolean isShowDiagram() {
+ return diagram.isShowDiagram();
+ }
+
+ @Override
+ public boolean handleKeyEvent(KeyEvent ke) {
+ if (diagram.handleScrollKeys(ke)) {
+ return true;
+ }
+
+ // Toggle metrics
+ if (diagram.isShowDiagram() && ke.isCharIgnoreCase('m')) {
+ diagramMetrics = !diagramMetrics;
+ diagram.endLoad();
+ reloadDiagram();
+ return true;
+ }
+
+ // Toggle description
+ if (diagram.isShowDiagram() && ke.isCharIgnoreCase('n')) {
+ diagram.setShowDescription(!diagram.isShowDescription());
+ diagram.endLoad();
+ reloadDiagram();
+ return true;
+ }
+
+ // Drill down into route diagram (Enter)
+ if (topologyMode && ke.isConfirm()) {
+ // For now, drill-down is a future feature
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ public boolean handleEscape() {
+ if (!topologyMode) {
+ topologyMode = true;
+ diagram.setTopologyMode(true);
+ diagram.endLoad();
+ reloadDiagram();
+ return true;
+ }
+ return diagram.handleEscape();
+ }
+
+ @Override
+ public void navigateUp() {
+ // Scroll diagram up
+ }
+
+ @Override
+ public void navigateDown() {
+ // Scroll diagram down
+ }
+
+ @Override
+ public void onTabSelected() {
+ if (!diagram.isShowDiagram()) {
+ diagram.toggleTextDiagram(this::loadDiagram);
+ }
+ }
+
+ @Override
+ public void onIntegrationChanged() {
+ topologyMode = true;
+ drillDownRouteId = null;
+ diagram.reset();
+ diagram.setTopologyMode(true);
+ }
+
+ @Override
+ public void render(Frame frame, Rect area) {
+ IntegrationInfo info = ctx.findSelectedIntegration();
+ if (info == null) {
+ renderNoSelection(frame, area);
+ return;
+ }
+
+ if (diagram.isShowDiagram() && diagram.hasDiagramData()) {
+ String title;
+ if (topologyMode) {
+ title = " Topology ";
+ } else {
+ title = " Route [" + drillDownRouteId + "] ";
+ }
+ diagram.renderDiagram(frame, area, title);
+ return;
+ }
+
+ // Show placeholder when no diagram is loaded yet
+ frame.renderWidget(
+ Paragraph.builder()
+ .text(Text.from(Line.from(Span.styled(
+ "Loading diagram...",
+ Style.EMPTY.dim()))))
+ .block(Block.builder().borderType(BorderType.ROUNDED)
+ .title(" Diagram ").build())
+ .build(),
+ area);
+ }
+
+ @Override
+ public void renderFooter(List<Span> spans) {
+ if (diagram.isShowDiagram()) {
+ diagram.renderFooterHints(spans);
+ hint(spans, "m", "metrics" + (diagramMetrics ? " [on]" : " [off]"));
+ hint(spans, "n", "description" + (diagram.isShowDescription() ? " [on]" : " [off]"));
+ }
+ }
+
+ void refreshDiagramIfNeeded() {
+ if (diagram.isShowDiagram() && diagramMetrics) {
+ reloadDiagram();
+ }
+ }
+
+ private void loadDiagram() {
+ loadDiagram(true);
+ }
+
+ private void reloadDiagram() {
+ loadDiagram(false);
+ }
+
+ private void loadDiagram(boolean showPlaceholder) {
+ if (ctx.selectedPid == null || ctx.runner == null) {
+ return;
+ }
+ if (!diagram.beginLoad()) {
+ return;
+ }
+
+ String pid = ctx.selectedPid;
+ boolean showMetrics = diagramMetrics;
+
+ if (showPlaceholder) {
+ diagram.setLoadingPlaceholder();
+ }
+
+ ctx.runner.scheduler().execute(() -> {
+ try {
+ if (topologyMode) {
+ diagram.setTopologyMode(true);
+ diagram.loadTopologyDiagramInBackground(ctx, pid, true, showMetrics);
+ } else {
+ diagram.setTopologyMode(false);
+ diagram.loadRouteDiagramInBackground(ctx, pid, true, drillDownRouteId, showMetrics);
+ }
+ } finally {
+ diagram.endLoad();
+ }
+ });
+ }
+
+ @Override
+ public String getHelpText() {
+ return """
+ # Diagram
+
+ The Diagram tab shows a visual topology of how routes connect to each other.
+ This helps you understand the overall message flow in your integration.
+
+ ## Topology View
+
+ The topology view shows all routes and their connections:
+ - **Trigger routes** (timer, cron, etc.) appear at the top
+ - **Downstream routes** appear below, connected by arrows
+ - Routes that are connected show edges between them
+
+ ## Example Topology
+
+ ```
+ ┌──────────────────┐
+ │ order-generator │
+ │ (timer://gen) │
+ │ │
+ │ 3748 │
+ └──────────────────┘
+ │
+ ▼
+ ┌──────────────────┐
+ │ process-order │
+ │ (direct:process) │
+ │ │
+ │ 3748/12! │
+ └──────────────────┘
+ ```
+
+ Each box represents a route. The first line is the route ID,
+ the second line shows the `from` endpoint, and the bottom line
+ shows metrics when enabled. Arrows show how routes connect.
+
+ ## Metrics
+
+ When metrics are enabled, each route box shows exchange counts:
+ - **Green** number — successful exchanges
+ - **Red** number with `!` — failed exchanges
+ - Combined as `3748/12!` means 3748 ok and 12 failed
+
+ ## Keys
+
+ - `m` — toggle metrics on/off (default: on)
+ - `↑↓←→` — scroll diagram
+ - `PgUp/PgDn` — page scroll
+ - `Home/End` — top/end
+ - `Esc` — close diagram
+ """;
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
index f2dfdc479648..0a3053303ac2 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
@@ -110,11 +110,7 @@ class HistoryTab implements MonitorTab {
if (diagram.handleScrollKeys(ke)) {
return true;
}
- if (ke.isChar('d')) {
- diagram.toggleImageDiagram(this::loadDiagramForCurrentView);
- return true;
- }
- if (ke.isChar('D')) {
+ if (ke.isCharIgnoreCase('d')) {
diagram.toggleTextDiagram(this::loadDiagramForCurrentView);
return true;
}
@@ -405,7 +401,6 @@ class HistoryTab implements MonitorTab {
hint(spans, "g", "waterfall" + (showWaterfall ? " [on]" : ""));
if (!showWaterfall) {
hint(spans, "d", "diagram");
- hint(spans, "D", "text diagram");
hint(spans, "p", "properties" + (showTraceProperties ? " [on]" : " [off]"));
hint(spans, "v", "variables" + (showTraceVariables ? " [on]" : " [off]"));
hint(spans, "h", "headers" + (showTraceHeaders ? " [on]" : " [off]"));
@@ -417,7 +412,6 @@ class HistoryTab implements MonitorTab {
hint(spans, "↑↓", "navigate");
hint(spans, "s", "sort");
hint(spans, "d", "diagram");
- hint(spans, "D", "text diagram");
hint(spans, "Enter", "details");
hintLast(spans, "F5", "refresh");
} else {
@@ -430,7 +424,6 @@ class HistoryTab implements MonitorTab {
hint(spans, "g", "waterfall" + (showWaterfall ? " [on]" : ""));
if (!showWaterfall) {
hint(spans, "d", "diagram");
- hint(spans, "D", "text diagram");
hint(spans, "p", "properties" + (showHistoryProperties ? " [on]" : " [off]"));
hint(spans, "v", "variables" + (showHistoryVariables ? " [on]" : " [off]"));
hint(spans, "h", "headers" + (showHistoryHeaders ? " [on]" : " [off]"));
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
index 766c43e966a8..295dba6e591b 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
@@ -206,17 +206,20 @@ class RoutesTab implements MonitorTab {
return true;
}
- // Image diagram toggle
- if (ke.isChar('d')) {
- diagram.toggleImageDiagram(this::loadDiagramForSelectedRoute);
- return true;
- }
// Text diagram toggle
- if (ke.isChar('D')) {
+ if (ke.isCharIgnoreCase('d')) {
diagram.toggleTextDiagram(this::loadDiagramForSelectedRoute);
return true;
}
+ // Toggle description in diagram
+ if (diagram.isShowDiagram() && ke.isCharIgnoreCase('n')) {
+ diagram.setShowDescription(!diagram.isShowDescription());
+ diagram.endLoad();
+ loadDiagramForSelectedRoute();
+ return true;
+ }
+
// Source viewer toggle
if (ke.isChar('c')) {
if (showSource) {
@@ -470,10 +473,11 @@ class RoutesTab implements MonitorTab {
hint(spans, "c/Esc", "close");
hint(spans, "↑↓←→", "scroll");
hint(spans, "PgUp/PgDn", "page");
- hintLast(spans, "Home/End", "top/bottom");
+ hintLast(spans, "Home/End", "top/end");
} else if (diagram.isShowDiagram()) {
diagram.renderFooterHints(spans);
- hintLast(spans, "m", "metrics" + (diagramMetrics ? " [on]" : " [off]"));
+ hint(spans, "m", "metrics" + (diagramMetrics ? " [on]" : " [off]"));
+ hintLast(spans, "n", "description" + (diagram.isShowDescription() ? " [on]" : " [off]"));
} else {
hint(spans, "Esc", "back");
hint(spans, "↑↓", "navigate");
@@ -482,7 +486,6 @@ class RoutesTab implements MonitorTab {
if (!routeTopMode) {
hint(spans, "c", "source");
hint(spans, "d", "diagram");
- hint(spans, "D", "text diagram");
hint(spans, "a", "diagram " + (diagramAllRoutes ? "[all]" : "[single]"));
String routeState = selectedRouteState();
boolean supSus = selectedRouteSupportsSuspension();
@@ -1271,7 +1274,6 @@ class RoutesTab implements MonitorTab {
- `p` — start/stop selected route
- `P` — suspend/resume selected route
- `d` — show route diagram
- - `D` — show text diagram
- `a` — toggle diagram scope (single route or all routes)
- `c` — show route source code
- `m` — toggle metrics in diagram
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StartupTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StartupTab.java
index c539ab206967..67793824d3cc 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StartupTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StartupTab.java
@@ -413,7 +413,7 @@ class StartupTab implements MonitorTab {
- `Up/Down` — scroll through steps
- `PgUp/PgDn` — scroll by page
- - `Home/End` — jump to top/bottom
+ - `Home/End` — jump to top/end
- `F5` — reload startup data
- `Esc` — back
""";