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 aa85dc40b626 Add external endpoints to route topology and TUI
enhancements (#23701)
aa85dc40b626 is described below
commit aa85dc40b6264af406f4f316218bedea41ba3294
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Jun 2 17:09:44 2026 +0200
Add external endpoints to route topology and TUI enhancements (#23701)
* Add external endpoint support to route topology dumper
Adds three-band topology view showing external systems that Camel routes
communicate with: consumers (incoming) at top, routes in middle, and
producers (outgoing) at bottom. Opt-in via --external flag on
camel cmd route-topology or external=true on the DevConsole.
Uses Endpoint.isRemote() to classify endpoints as remote vs internal.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Fix external option not passed from CLI connector to DevConsole
The LocalCliConnector was not forwarding the "external" parameter
from the action JSON to the RouteTopologyDevConsole, so external
endpoints were never included in the response.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Skip stub endpoints when building scheme remote map
When components are stubbed (e.g. --stub=kafka), StubEndpoint.isRemote()
returns false which incorrectly masks the real component's remote status.
Skip stub endpoints so the fallback logic correctly treats schemes like
kafka as remote.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Filter external endpoints to exclude inter-route connections and add
dashed edges
External endpoints that serve as inter-route connections (e.g. kafka
topics consumed by one route and produced to by another) are now
filtered out. Only truly external endpoints remain: consumers where
no route sends to that URI, and producers where no route consumes
from that URI.
Also adds dashed line rendering for external edges in the ASCII/Unicode
topology renderer.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Only use dashed edges for connections to/from external nodes
Edges between route nodes stay solid even if the component is remote
(e.g. kafka inter-route). Dashed lines are reserved for edges that
connect to external-in or external-out nodes.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Add per-endpoint metrics for external endpoints in topology
External-in endpoints use route-level metrics (1 consumer per route).
External-out endpoints use processor-level metrics from the matching
send processor, giving the actual count of messages sent to that
specific endpoint rather than the route total. Multiple send processors
to the same destination are aggregated.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Fix URI matching for external endpoint metrics
Normalize scheme://path to scheme:path before comparing destination
URIs from send processors against external endpoint URIs, since
endpoint URIs may use either format.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Use URISupport.normalizeUri for endpoint metric matching
Replace custom normalizeScheme helper with the standard
URISupport.normalizeUri for consistent URI comparison.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Use dashed boxes for external nodes and fix URI matching
External endpoint nodes now render with dashed borders (both ASCII
and image) to visually distinguish them from route nodes.
Fixed endpoint metric URI matching to use simple :// stripping
instead of URISupport.normalizeUri which can throw exceptions and
silently skip endpoints.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Narrow exception handling in endpoint metric collection
Move try/catch to per-processor scope so one failing processor does
not skip the entire route. Add null check on sp.getDestination().
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Fix ANSI coloring for multiple external nodes on the same row
Sort counter positions right-to-left (descending column) within each row
before applying ANSI escape codes. This prevents insertions from shifting
character offsets for subsequent positions on the same line.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Add external systems toggle to TUI diagram tab
Press 'e' to toggle external systems on/off in the topology diagram.
When enabled, the diagram shows a three-band layout with external
consumers at the top, routes in the middle, and external producers
at the bottom. External node names are colored cyan in the TUI.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Add stub checkbox and failure log dialog to TUI run options
Add a "Stub (no Docker needed)" checkbox to the run options form that
passes --stub=all and skips infra service startup. When an example
launch fails, show the full log output in a scrollable dialog instead
of a brief notification. Use TuiHelper.ansiToLine for proper ANSI/control
character handling and Overflow.CLIP to prevent rendering artifacts.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Sync route-topology example application.properties from GitHub
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Add timer, cron, and scheduler to accepted stub names
These local/scheduling components should not be stubbed when using
--stub=all, as they don't require external infrastructure.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Handle Duration type in export/validator magic value fallback
Co-Authored-By: Claude <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
---
.../camel/diagram/RouteDiagramAsciiRenderer.java | 3 +-
.../apache/camel/diagram/RouteDiagramRenderer.java | 6 +
.../camel/diagram/TopologyAsciiRenderer.java | 45 ++++--
.../org/apache/camel/diagram/TopologyHelper.java | 49 +++++++
.../camel/diagram/TopologyImageRenderer.java | 33 ++++-
.../apache/camel/diagram/TopologyLayoutEngine.java | 101 ++++++++++---
.../apache/camel/diagram/TopologyDiagramTest.java | 159 +++++++++++++++++++++
.../org/apache/camel/spi/RouteTopologyDumper.java | 24 +++-
.../impl/console/RouteTopologyDevConsole.java | 115 +++++++++++++++
.../camel/impl/DefaultRouteTopologyDumper.java | 84 ++++++++++-
.../camel-jbang-cmd-route-topology.adoc | 1 +
.../camel/cli/connector/LocalCliConnector.java | 4 +-
dsl/camel-jbang/camel-jbang-core/pom.xml | 4 +
.../META-INF/camel-jbang-commands-metadata.json | 2 +-
.../commands/action/CamelRouteTopologyAction.java | 7 +
.../examples/camel-jbang-example-catalog.json | 23 +++
.../resources/examples/route-topology/README.md | 61 ++++++++
.../examples/route-topology/application.properties | 3 +
.../route-topology/route-topology.camel.yaml | 97 +++++++++++++
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 65 +++++++--
.../jbang/core/commands/tui/DiagramSupport.java | 17 ++-
.../dsl/jbang/core/commands/tui/DiagramTab.java | 25 +++-
.../jbang/core/commands/tui/RunOptionsForm.java | 32 +++--
.../DependencyDownloaderComponentResolver.java | 3 +-
.../camel/main/download/ExportTypeConverter.java | 4 +
.../dsl/yaml/validator/DummyTypeConverter.java | 4 +
26 files changed, 897 insertions(+), 74 deletions(-)
diff --git
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java
index 654c166a7114..d32e850300c0 100644
---
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java
+++
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java
@@ -65,7 +65,8 @@ public class RouteDiagramAsciiRenderer {
OK,
FAIL,
HIGHLIGHT_SUCCESS,
- HIGHLIGHT_FAIL
+ HIGHLIGHT_FAIL,
+ EXTERNAL
}
public record CounterPos(int row, int col, int length, CounterType type) {
diff --git
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java
index 157b3605ceb6..9da413e0ef0c 100644
---
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java
+++
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java
@@ -119,6 +119,7 @@ public class RouteDiagramRenderer {
private Color nodeDefault;
private Color nodeTransform;
private Color nodeProcessor;
+ private Color nodeExternal;
public static DiagramColors parse(String spec) {
String resolved = COLOR_PRESETS.getOrDefault(spec, spec);
@@ -149,6 +150,7 @@ public class RouteDiagramRenderer {
c.nodeDefault = parseColor(map.getOrDefault("default", "#455a64"));
c.nodeTransform = parseColor(map.getOrDefault("transform",
"#00838f"));
c.nodeProcessor = parseColor(map.getOrDefault("processor",
"#d84315"));
+ c.nodeExternal = parseColor(map.getOrDefault("external",
"#0277bd"));
return c;
}
@@ -221,6 +223,10 @@ public class RouteDiagramRenderer {
public Color getNodeProcessor() {
return nodeProcessor;
}
+
+ public Color getNodeExternal() {
+ return nodeExternal;
+ }
}
public BufferedImage renderDiagram(
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 25c6f23dcd4b..84bbbd065f2f 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
@@ -18,6 +18,7 @@ package org.apache.camel.diagram;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Comparator;
import java.util.List;
import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutEdge;
@@ -44,6 +45,8 @@ public class TopologyAsciiRenderer {
private static final char UNI_T_UP = '┴';
private static final char UNI_CROSS = '┼';
private static final char UNI_ARROW = '▼';
+ private static final char UNI_DASH_V = '┆';
+ private static final char UNI_DASH_H = '┄';
private final int nodeWidth;
private final int boxWidth;
@@ -122,12 +125,18 @@ public class TopologyAsciiRenderer {
return gridToString(grid);
}
+ private static boolean isExternalNode(TopologyLayoutNode node) {
+ return "external-in".equals(node.nodeType) ||
"external-out".equals(node.nodeType);
+ }
+
private void drawNode(char[][] grid, TopologyLayoutNode node) {
int col = toCol(node.x);
int row = toRow(node.y);
String line1;
- if (showDescription && node.description != null &&
!node.description.isBlank()) {
+ if (isExternalNode(node)) {
+ line1 = node.from;
+ } else if (showDescription && node.description != null &&
!node.description.isBlank()) {
line1 = node.description;
} else {
line1 = node.routeId;
@@ -135,7 +144,7 @@ public class TopologyAsciiRenderer {
List<String> lines = new ArrayList<>();
lines.addAll(wrapText(line1, boxWidth - 4));
- if (!showDescription) {
+ if (!isExternalNode(node) && !showDescription) {
String line2 = "(" + node.from + ")";
List<String> fromLines = wrapText(line2, boxWidth - 4);
lines.addAll(fromLines);
@@ -159,7 +168,7 @@ public class TopologyAsciiRenderer {
sb.append(node.exchangesFailed).append("!");
}
lines.add(sb.toString());
- } else {
+ } else if (!isExternalNode(node)) {
lines.add("");
}
}
@@ -174,8 +183,9 @@ public class TopologyAsciiRenderer {
return;
}
- char h = unicode ? UNI_H : '-';
- char v = unicode ? UNI_V : '|';
+ boolean ext = isExternalNode(node);
+ char h = ext ? (unicode ? UNI_DASH_H : '-') : (unicode ? UNI_H : '-');
+ char v = ext ? (unicode ? UNI_DASH_V : ':') : (unicode ? UNI_V : '|');
// Top border
setChar(grid, row, col, unicode ? UNI_TL : '+');
@@ -208,6 +218,10 @@ public class TopologyAsciiRenderer {
int textCol = col + 2 + Math.max(0, (innerWidth - text.length()) /
2);
drawText(grid, r, textCol, text);
+ // Track counter positions for ANSI coloring
+ if (isExternalNode(node) && i == 0) {
+ counterPositions.add(new CounterPos(r, textCol, text.length(),
CounterType.EXTERNAL));
+ }
if (metrics && i == lines.size() - 1 && node.exchangesTotal > 0) {
long ok = node.exchangesTotal - node.exchangesFailed;
if (ok > 0) {
@@ -233,8 +247,9 @@ public class TopologyAsciiRenderer {
return;
}
- char v = unicode ? UNI_V : '|';
- char h = unicode ? UNI_H : '-';
+ boolean dashed = isExternalNode(edge.from) || isExternalNode(edge.to);
+ char v = dashed ? (unicode ? UNI_DASH_V : ':') : (unicode ? UNI_V :
'|');
+ char h = dashed ? (unicode ? UNI_DASH_H : '-') : (unicode ? UNI_H :
'-');
char arrow = unicode ? UNI_ARROW : 'v';
if (fromCx == toCx) {
@@ -291,6 +306,13 @@ public class TopologyAsciiRenderer {
}
private int boxHeight(TopologyLayoutNode node) {
+ if (isExternalNode(node)) {
+ int lines = 1; // URI
+ if (metrics && node.exchangesTotal > 0) {
+ lines++;
+ }
+ return 2 + lines;
+ }
int lines = 3; // routeId + from (2 lines reserved)
if (metrics) {
lines++;
@@ -343,7 +365,10 @@ public class TopologyAsciiRenderer {
return plain;
}
String[] lines = plain.split("\n", -1);
- for (CounterPos cp : counterPositions) {
+ List<CounterPos> sorted = new ArrayList<>(counterPositions);
+ sorted.sort(
+
Comparator.comparingInt(CounterPos::row).thenComparing(Comparator.comparingInt(CounterPos::col).reversed()));
+ for (CounterPos cp : sorted) {
if (cp.row >= 0 && cp.row < lines.length) {
String line = lines[cp.row];
if (cp.col >= 0 && cp.col + cp.length <= line.length()) {
@@ -388,11 +413,11 @@ public class TopologyAsciiRenderer {
}
private boolean isVertical(char ch) {
- return ch == '|' || ch == UNI_V;
+ return ch == '|' || ch == UNI_V || ch == ':' || ch == UNI_DASH_V;
}
private boolean isHorizontal(char ch) {
- return ch == '-' || ch == UNI_H;
+ return ch == '-' || ch == UNI_H || ch == UNI_DASH_H;
}
private void plotLine(char[][] grid, int row, int col, char ch) {
diff --git
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyHelper.java
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyHelper.java
index 64d54f8a5f03..ce48c0d105a5 100644
---
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyHelper.java
+++
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyHelper.java
@@ -72,6 +72,55 @@ public final class TopologyHelper {
return edges;
}
+ /**
+ * Parses external endpoints from the JSON and adds them as nodes and
edges to the existing lists. External
+ * endpoints with direction "in" (consumers) become nodes connected TO
their route. External endpoints with
+ * direction "out" (producers) become nodes connected FROM their route.
+ */
+ public static void addExternalEndpoints(List<TopologyNodeInfo> nodes,
List<TopologyEdgeInfo> edges, JsonObject jo) {
+ JsonArray arr = jo.getJsonArray("externalEndpoints");
+ if (arr == null) {
+ return;
+ }
+ for (int i = 0; i < arr.size(); i++) {
+ JsonObject eo = arr.getJsonObject(i);
+ String id = eo.getString("id");
+ String uri = eo.getString("uri");
+ String scheme = eo.getString("scheme");
+ String direction = eo.getString("direction");
+ String routeId = eo.getString("routeId");
+
+ // Create a node for this external endpoint
+ TopologyNodeInfo node = new TopologyNodeInfo();
+ node.routeId = id;
+ node.from = uri;
+ node.fromScheme = scheme;
+ node.nodeType = "in".equals(direction) ? "external-in" :
"external-out";
+
+ // Extract context-path from URI for use as description
+ int colonIdx = uri.indexOf(':');
+ node.description = colonIdx > 0 ? uri.substring(colonIdx + 1) :
uri;
+
+ node.exchangesTotal = eo.getLongOrDefault("exchangesTotal", 0);
+ node.exchangesFailed = eo.getLongOrDefault("exchangesFailed", 0);
+
+ nodes.add(node);
+
+ // Create an edge connecting this external endpoint to/from its
route
+ TopologyEdgeInfo edge = new TopologyEdgeInfo();
+ edge.endpoint = uri;
+ edge.connectionType = "external";
+ if ("in".equals(direction)) {
+ edge.fromRouteId = id;
+ edge.toRouteId = routeId;
+ } else {
+ edge.fromRouteId = routeId;
+ edge.toRouteId = id;
+ }
+ edges.add(edge);
+ }
+ }
+
public static void enrichWithMetrics(List<TopologyNodeInfo> nodes,
JsonObject routeStructureJson) {
if (routeStructureJson == null) {
return;
diff --git
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyImageRenderer.java
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyImageRenderer.java
index 977b5283cf63..9a04390863d1 100644
---
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyImageRenderer.java
+++
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyImageRenderer.java
@@ -89,7 +89,13 @@ public class TopologyImageRenderer {
continue;
}
- g.setStroke(new BasicStroke(strokeWidth));
+ boolean isExternalEdge = isExternalNode(edge.from) ||
isExternalNode(edge.to);
+ if (isExternalEdge) {
+ float[] dash = { 8 * strokeWidth, 6 * strokeWidth };
+ g.setStroke(new BasicStroke(strokeWidth, BasicStroke.CAP_BUTT,
BasicStroke.JOIN_MITER, 10f, dash, 0f));
+ } else {
+ g.setStroke(new BasicStroke(strokeWidth));
+ }
g.setColor(colors.getArrow());
int fromCx = edge.from.x + nw / 2;
@@ -107,12 +113,17 @@ public class TopologyImageRenderer {
}
// Arrow head
+ g.setStroke(new BasicStroke(strokeWidth));
int[] xPoints = { toCx - arrowSize, toCx + arrowSize, toCx };
int[] yPoints = { toTy - arrowSize, toTy - arrowSize, toTy };
g.fillPolygon(xPoints, yPoints, 3);
}
}
+ private static boolean isExternalNode(TopologyLayoutNode node) {
+ return "external-in".equals(node.nodeType) ||
"external-out".equals(node.nodeType);
+ }
+
private static void drawNodes(
Graphics2D g, TopologyLayoutResult result, DiagramColors colors,
Font font, FontMetrics fm, int nw, int fontSizeScaled,
@@ -120,7 +131,9 @@ public class TopologyImageRenderer {
for (TopologyLayoutNode node : result.nodes) {
Color nodeColor;
- if ("trigger".equals(node.nodeType)) {
+ if (isExternalNode(node)) {
+ nodeColor = colors.getNodeExternal();
+ } else if ("trigger".equals(node.nodeType)) {
nodeColor = colors.getNodeFrom();
} else {
nodeColor = colors.getNodeDefault();
@@ -128,16 +141,26 @@ public class TopologyImageRenderer {
// Node box
g.setColor(nodeColor);
- g.setStroke(new BasicStroke(strokeWidth));
+ if (isExternalNode(node)) {
+ float[] dash = { 6 * strokeWidth, 4 * strokeWidth };
+ g.setStroke(
+ new BasicStroke(strokeWidth, BasicStroke.CAP_BUTT,
BasicStroke.JOIN_MITER, 10f, dash, 0f));
+ } else {
+ g.setStroke(new BasicStroke(strokeWidth));
+ }
g.fillRoundRect(node.x, node.y, nw, node.height, arc, arc);
g.setColor(nodeColor.brighter());
g.drawRoundRect(node.x, node.y, nw, node.height, arc, arc);
+ g.setStroke(new BasicStroke(strokeWidth));
// Text
g.setColor(colors.getText());
String line1;
- if (showDescription && node.description != null &&
!node.description.isBlank()) {
+ if (isExternalNode(node)) {
+ // External nodes show scheme as primary, context-path as
secondary
+ line1 = node.from;
+ } else if (showDescription && node.description != null &&
!node.description.isBlank()) {
line1 = node.description;
} else {
line1 = node.routeId;
@@ -145,7 +168,7 @@ public class TopologyImageRenderer {
int lineHeight = fm.getHeight();
int textY;
- if (showDescription) {
+ if (isExternalNode(node) || showDescription) {
textY = node.y + (node.height - lineHeight) / 2 +
fm.getAscent();
int line1Width = fm.stringWidth(line1);
g.drawString(line1, node.x + (nw - line1Width) / 2, textY);
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 b00026da41f0..264e0a2fe4a2 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
@@ -27,12 +27,16 @@ import java.util.Set;
/**
* Layered directed graph layout engine for route topology diagrams. Uses a
simplified Sugiyama algorithm: layer
* assignment, crossing minimization, and coordinate assignment.
+ *
+ * When external endpoint nodes are present (nodeType "external-in" or
"external-out"), the layout uses a three-band
+ * approach: consumers at top, routes in middle, producers at bottom.
*/
public class TopologyLayoutEngine {
static final int SCALE = RouteDiagramLayoutEngine.SCALE;
static final int V_GAP = 50 * SCALE;
static final int H_GAP = 30 * SCALE;
+ static final int BAND_GAP = 80 * SCALE;
static final int PADDING = RouteDiagramLayoutEngine.PADDING;
public static final int DEFAULT_NODE_WIDTH = 180;
static final int DEFAULT_NODE_HEIGHT = 40;
@@ -63,6 +67,20 @@ public class TopologyLayoutEngine {
return new TopologyLayoutResult(Collections.emptyList(),
Collections.emptyList(), 0, 0);
}
+ // Separate external nodes from route nodes
+ List<TopologyNodeInfo> externalInNodes = new ArrayList<>();
+ List<TopologyNodeInfo> externalOutNodes = new ArrayList<>();
+ List<TopologyNodeInfo> routeNodes = new ArrayList<>();
+ for (TopologyNodeInfo n : nodes) {
+ if ("external-in".equals(n.nodeType)) {
+ externalInNodes.add(n);
+ } else if ("external-out".equals(n.nodeType)) {
+ externalOutNodes.add(n);
+ } else {
+ routeNodes.add(n);
+ }
+ }
+
Map<String, TopologyNodeInfo> nodeMap = new HashMap<>();
for (TopologyNodeInfo n : nodes) {
nodeMap.put(n.routeId, n);
@@ -81,8 +99,32 @@ public class TopologyLayoutEngine {
}
}
- // Layer assignment
- Map<String, Integer> layers = assignLayers(nodes, successors,
predecessors);
+ boolean hasExternalIn = !externalInNodes.isEmpty();
+ boolean hasExternalOut = !externalOutNodes.isEmpty();
+
+ // Layer assignment for route nodes only
+ Map<String, Integer> layers = assignRouteLayers(routeNodes,
successors, predecessors);
+
+ // Shift route layers to make room for external-in band
+ if (hasExternalIn) {
+ for (Map.Entry<String, Integer> entry : layers.entrySet()) {
+ entry.setValue(entry.getValue() + 1);
+ }
+ }
+
+ // Place external-in nodes at layer 0
+ for (TopologyNodeInfo n : externalInNodes) {
+ layers.put(n.routeId, 0);
+ }
+
+ // Place external-out nodes at max route layer + 1
+ int maxRouteLayer = layers.values().stream()
+ .filter(l -> !externalOutNodes.stream().anyMatch(n ->
layers.getOrDefault(n.routeId, -1).equals(l)))
+ .mapToInt(Integer::intValue).max().orElse(0);
+ int outLayer = maxRouteLayer + 1;
+ for (TopologyNodeInfo n : externalOutNodes) {
+ layers.put(n.routeId, outLayer);
+ }
// Group nodes by layer
int maxLayer =
layers.values().stream().mapToInt(Integer::intValue).max().orElse(0);
@@ -98,8 +140,11 @@ public class TopologyLayoutEngine {
// Minimize crossings (barycenter heuristic)
minimizeCrossings(layerGroups, successors, predecessors);
- // Assign coordinates
- Map<String, TopologyLayoutNode> layoutNodes =
assignCoordinates(layerGroups, nodeMap);
+ // Assign coordinates with extra gap between bands
+ int externalInLayer = hasExternalIn ? 0 : -1;
+ int externalOutLayer = hasExternalOut ? outLayer : -1;
+ Map<String, TopologyLayoutNode> layoutNodes
+ = assignCoordinates(layerGroups, nodeMap, externalInLayer,
externalOutLayer);
// Build layout edges
List<TopologyLayoutEdge> layoutEdges = new ArrayList<>();
@@ -121,37 +166,42 @@ public class TopologyLayoutEngine {
new ArrayList<>(layoutNodes.values()), layoutEdges,
totalWidth, totalHeight);
}
- private Map<String, Integer> assignLayers(
- List<TopologyNodeInfo> nodes,
+ private Map<String, Integer> assignRouteLayers(
+ List<TopologyNodeInfo> routeNodes,
Map<String, List<String>> successors,
Map<String, List<String>> predecessors) {
Map<String, Integer> layers = new HashMap<>();
+ Set<String> routeIds = new HashSet<>();
+ for (TopologyNodeInfo n : routeNodes) {
+ routeIds.add(n.routeId);
+ }
- // Triggers and nodes with no predecessors go to layer 0
+ // Triggers and nodes with no route predecessors go to layer 0
Set<String> assigned = new HashSet<>();
- for (TopologyNodeInfo n : nodes) {
- if ("trigger".equals(n.nodeType) ||
predecessors.get(n.routeId).isEmpty()) {
+ for (TopologyNodeInfo n : routeNodes) {
+ boolean hasRoutePredecessor =
predecessors.get(n.routeId).stream().anyMatch(routeIds::contains);
+ if ("trigger".equals(n.nodeType) || !hasRoutePredecessor) {
layers.put(n.routeId, 0);
assigned.add(n.routeId);
}
}
// If nothing assigned (all cycles), pick first node
- if (assigned.isEmpty() && !nodes.isEmpty()) {
- layers.put(nodes.get(0).routeId, 0);
- assigned.add(nodes.get(0).routeId);
+ if (assigned.isEmpty() && !routeNodes.isEmpty()) {
+ layers.put(routeNodes.get(0).routeId, 0);
+ assigned.add(routeNodes.get(0).routeId);
}
- // BFS-style layer assignment
+ // BFS-style layer assignment (only follow edges to other route nodes)
boolean changed = true;
while (changed) {
changed = false;
- for (TopologyNodeInfo n : nodes) {
+ for (TopologyNodeInfo n : routeNodes) {
if (assigned.contains(n.routeId)) {
for (String succ : successors.get(n.routeId)) {
- if (succ.equals(n.routeId)) {
- continue; // skip self-loops
+ if (succ.equals(n.routeId) ||
!routeIds.contains(succ)) {
+ continue;
}
int newLayer = layers.get(n.routeId) + 1;
if (!assigned.contains(succ) || layers.get(succ) <
newLayer) {
@@ -164,8 +214,8 @@ public class TopologyLayoutEngine {
}
}
- // Handle any unassigned nodes (isolated or in pure cycles)
- for (TopologyNodeInfo n : nodes) {
+ // Handle any unassigned route nodes (isolated or in pure cycles)
+ for (TopologyNodeInfo n : routeNodes) {
layers.putIfAbsent(n.routeId, 0);
}
@@ -223,7 +273,9 @@ public class TopologyLayoutEngine {
private Map<String, TopologyLayoutNode> assignCoordinates(
List<List<String>> layerGroups,
- Map<String, TopologyNodeInfo> nodeMap) {
+ Map<String, TopologyNodeInfo> nodeMap,
+ int externalInLayer,
+ int externalOutLayer) {
Map<String, TopologyLayoutNode> layoutNodes = new HashMap<>();
@@ -234,11 +286,11 @@ public class TopologyLayoutEngine {
maxLayerWidth = Math.max(maxLayerWidth, width);
}
+ int cumulativeY = PADDING;
for (int layerIdx = 0; layerIdx < layerGroups.size(); layerIdx++) {
List<String> layer = layerGroups.get(layerIdx);
int layerWidth = layer.size() * (nodeWidth + H_GAP) - H_GAP;
int startX = PADDING + (maxLayerWidth - layerWidth) / 2;
- int y = PADDING + layerIdx * (nodeHeight + V_GAP);
for (int i = 0; i < layer.size(); i++) {
String routeId = layer.get(i);
@@ -246,11 +298,18 @@ public class TopologyLayoutEngine {
int x = startX + i * (nodeWidth + H_GAP);
TopologyLayoutNode ln = new TopologyLayoutNode(
routeId, info.description, info.from, info.nodeType,
info.connectionType,
- x, y, nodeWidth, nodeHeight, layerIdx);
+ x, cumulativeY, nodeWidth, nodeHeight, layerIdx);
ln.exchangesTotal = info.exchangesTotal;
ln.exchangesFailed = info.exchangesFailed;
layoutNodes.put(routeId, ln);
}
+
+ // Add vertical gap; use larger gap at band boundaries
+ int gap = V_GAP;
+ if (layerIdx == externalInLayer || (externalOutLayer >= 0 &&
layerIdx == externalOutLayer - 1)) {
+ gap = BAND_GAP;
+ }
+ cumulativeY += nodeHeight + gap;
}
return layoutNodes;
diff --git
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyDiagramTest.java
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyDiagramTest.java
index 1d6b773d0da2..d94497ef87ea 100644
---
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyDiagramTest.java
+++
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyDiagramTest.java
@@ -16,6 +16,7 @@
*/
package org.apache.camel.diagram;
+import java.util.ArrayList;
import java.util.List;
import org.apache.camel.diagram.TopologyLayoutEngine.TopologyEdgeInfo;
@@ -324,6 +325,164 @@ class TopologyDiagramTest {
assertTrue(output.contains("fulfillment"));
}
+ @Test
+ void testExternalEndpointBands() {
+ // Routes
+ List<TopologyNodeInfo> nodes = new ArrayList<>(
+ List.of(
+ node("order-api", "platform-http:/api/orders",
"route"),
+ node("process-order", "direct:process-order",
"route")));
+
+ // Inter-route edges
+ List<TopologyEdgeInfo> edges = new ArrayList<>(
+ List.of(
+ edge("order-api", "process-order",
"direct:process-order", "internal")));
+
+ // External endpoints: 1 consumer (in) and 1 producer (out)
+ nodes.add(node("in-order-api", "platform-http:/api/orders",
"external-in"));
+ edges.add(edge("in-order-api", "order-api",
"platform-http:/api/orders", "external"));
+ nodes.add(node("out-process-order-0", "kafka:orders", "external-out"));
+ edges.add(edge("process-order", "out-process-order-0", "kafka:orders",
"external"));
+
+ TopologyLayoutEngine engine = new TopologyLayoutEngine();
+ TopologyLayoutResult result = engine.layout(nodes, edges);
+
+ assertEquals(4, result.nodes.size());
+
+ TopologyLayoutNode extIn = findNode(result, "in-order-api");
+ TopologyLayoutNode orderApi = findNode(result, "order-api");
+ TopologyLayoutNode processOrder = findNode(result, "process-order");
+ TopologyLayoutNode extOut = findNode(result, "out-process-order-0");
+
+ // Three-band layout: external-in at top, routes in middle,
external-out at bottom
+ assertEquals(0, extIn.layer);
+ assertTrue(orderApi.layer > extIn.layer, "Route should be below
external-in");
+ assertTrue(processOrder.layer > extIn.layer, "Route should be below
external-in");
+ assertTrue(extOut.layer > orderApi.layer, "External-out should be
below routes");
+ assertTrue(extOut.layer > processOrder.layer, "External-out should be
below routes");
+
+ // Verify Y coordinates follow the band ordering
+ assertTrue(extIn.y < orderApi.y, "External-in should be visually above
routes");
+ assertTrue(extOut.y > processOrder.y, "External-out should be visually
below routes");
+ }
+
+ @Test
+ void testExternalEndpointRendering() {
+ List<TopologyNodeInfo> nodes = new ArrayList<>(
+ List.of(
+ node("myroute", "direct:start", "route")));
+ List<TopologyEdgeInfo> edges = new ArrayList<>();
+
+ // Add external-out node
+ nodes.add(node("out-myroute-0", "kafka:events", "external-out"));
+ edges.add(edge("myroute", "out-myroute-0", "kafka:events",
"external"));
+
+ TopologyLayoutEngine engine = new TopologyLayoutEngine();
+ TopologyLayoutResult result = engine.layout(nodes, edges);
+
+ TopologyAsciiRenderer renderer = new TopologyAsciiRenderer(
+ TopologyLayoutEngine.DEFAULT_NODE_WIDTH *
TopologyLayoutEngine.SCALE, true);
+ String output = renderer.renderDiagram(result);
+
+ assertNotNull(output);
+ assertTrue(output.contains("myroute"));
+ assertTrue(output.contains("kafka:events"));
+ }
+
+ @Test
+ void testOrderProcessingWithExternalEndpoints() {
+ // Full order processing topology
+ // Only platform-http is truly external (no route sends to it).
+ // All kafka topics link routes internally, so they are NOT external.
+ List<TopologyNodeInfo> nodes = new ArrayList<>(
+ List.of(
+ node("order-generator", "timer:orders", "trigger"),
+ node("order-api", "platform-http:/api/orders",
"route"),
+ node("process-order", "direct:process-order", "route"),
+ node("validate-order", "direct:validate-order",
"route"),
+ node("order-dispatcher", "kafka:orders", "route"),
+ node("fulfillment", "kafka:fulfillment", "route"),
+ node("notification", "kafka:notifications", "route")));
+
+ List<TopologyEdgeInfo> edges = new ArrayList<>(
+ List.of(
+ edge("order-generator", "process-order",
"direct:process-order", "internal"),
+ edge("order-api", "process-order",
"direct:process-order", "internal"),
+ edge("process-order", "validate-order",
"direct:validate-order", "internal"),
+ edge("process-order", "order-dispatcher",
"kafka:orders", "external"),
+ edge("order-dispatcher", "fulfillment",
"kafka:fulfillment", "external"),
+ edge("order-dispatcher", "notification",
"kafka:notifications", "external")));
+
+ // Only platform-http is truly external (messages arrive from outside
Camel)
+ nodes.add(node("in-order-api", "platform-http:/api/orders",
"external-in"));
+ edges.add(edge("in-order-api", "order-api",
"platform-http:/api/orders", "external"));
+
+ TopologyLayoutEngine engine = new TopologyLayoutEngine();
+ TopologyLayoutResult result = engine.layout(nodes, edges);
+
+ // 7 routes + 1 external consumer = 8 nodes
+ assertEquals(8, result.nodes.size());
+
+ // Verify three-band ordering
+ TopologyLayoutNode extIn = findNode(result, "in-order-api");
+ TopologyLayoutNode route = findNode(result, "process-order");
+
+ assertEquals(0, extIn.layer, "External-in should be at layer 0");
+ assertTrue(route.layer > extIn.layer, "Routes should be below
external-in band");
+ }
+
+ @Test
+ void testJsonParsingWithExternalEndpoints() {
+ String json = """
+ {
+ "nodes": [
+ {"routeId": "r1", "from": "direct:start", "fromScheme":
"direct", "nodeType": "route"}
+ ],
+ "edges": [],
+ "externalEndpoints": [
+ {"id": "in-r1", "uri": "kafka:input", "scheme": "kafka",
"direction": "in", "routeId": "r1"},
+ {"id": "out-r1-0", "uri": "kafka:output", "scheme":
"kafka", "direction": "out", "routeId": "r1"}
+ ]
+ }
+ """;
+ org.apache.camel.util.json.JsonObject jo;
+ try {
+ jo = (org.apache.camel.util.json.JsonObject)
org.apache.camel.util.json.Jsoner.deserialize(json);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+
+ List<TopologyNodeInfo> nodes = TopologyHelper.parseNodes(jo);
+ List<TopologyEdgeInfo> edges = TopologyHelper.parseEdges(jo);
+ TopologyHelper.addExternalEndpoints(nodes, edges, jo);
+
+ // 1 route + 2 external endpoints = 3 nodes
+ assertEquals(3, nodes.size());
+ // 2 edges (one for each external endpoint)
+ assertEquals(2, edges.size());
+
+ // Verify external-in node
+ TopologyNodeInfo extIn = nodes.stream().filter(n ->
"in-r1".equals(n.routeId)).findFirst().orElse(null);
+ assertNotNull(extIn);
+ assertEquals("external-in", extIn.nodeType);
+ assertEquals("kafka:input", extIn.from);
+
+ // Verify external-out node
+ TopologyNodeInfo extOut = nodes.stream().filter(n ->
"out-r1-0".equals(n.routeId)).findFirst().orElse(null);
+ assertNotNull(extOut);
+ assertEquals("external-out", extOut.nodeType);
+ assertEquals("kafka:output", extOut.from);
+
+ // Verify edges: in -> r1, r1 -> out
+ TopologyEdgeInfo inEdge = edges.stream().filter(e ->
"in-r1".equals(e.fromRouteId)).findFirst().orElse(null);
+ assertNotNull(inEdge);
+ assertEquals("r1", inEdge.toRouteId);
+
+ TopologyEdgeInfo outEdge = edges.stream().filter(e ->
"out-r1-0".equals(e.toRouteId)).findFirst().orElse(null);
+ assertNotNull(outEdge);
+ assertEquals("r1", outEdge.fromRouteId);
+ }
+
private static TopologyNodeInfo node(String routeId, String from, String
nodeType) {
TopologyNodeInfo n = new TopologyNodeInfo();
n.routeId = routeId;
diff --git
a/core/camel-api/src/main/java/org/apache/camel/spi/RouteTopologyDumper.java
b/core/camel-api/src/main/java/org/apache/camel/spi/RouteTopologyDumper.java
index b78f99e425ff..68d6a6d5407b 100644
--- a/core/camel-api/src/main/java/org/apache/camel/spi/RouteTopologyDumper.java
+++ b/core/camel-api/src/main/java/org/apache/camel/spi/RouteTopologyDumper.java
@@ -57,14 +57,30 @@ public interface RouteTopologyDumper {
record TopologyEdge(String fromRouteId, String toRouteId, String endpoint,
String connectionType) {
}
+ /**
+ * An external endpoint representing a remote system that a route
communicates with.
+ *
+ * @param id a synthetic unique identifier
+ * @param uri the endpoint URI (scheme:context-path, query
parameters stripped)
+ * @param scheme the component scheme
+ * @param direction "in" for consumers (remote systems sending messages
into Camel) or "out" for producers (Camel
+ * sending messages to remote systems)
+ * @param routeId the route id that uses this external endpoint
+ * @since 4.21
+ */
+ record TopologyExternalEndpoint(String id, String uri, String scheme,
String direction, String routeId) {
+ }
+
/**
* The result of computing route topology.
*
- * @param nodes the route nodes
- * @param edges the connections between routes
- * @since 4.21
+ * @param nodes the route nodes
+ * @param edges the connections between routes
+ * @param externalEndpoints the external endpoints (remote systems) that
routes communicate with (may be empty)
+ * @since 4.21
*/
- record TopologyResult(List<TopologyNode> nodes, List<TopologyEdge> edges) {
+ record TopologyResult(List<TopologyNode> nodes, List<TopologyEdge> edges,
+ List<TopologyExternalEndpoint> externalEndpoints) {
}
/**
diff --git
a/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteTopologyDevConsole.java
b/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteTopologyDevConsole.java
index 112d35e60193..0caa74155434 100644
---
a/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteTopologyDevConsole.java
+++
b/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteTopologyDevConsole.java
@@ -16,17 +16,22 @@
*/
package org.apache.camel.impl.console;
+import java.util.Collection;
+import java.util.HashMap;
import java.util.Map;
import org.apache.camel.api.management.ManagedCamelContext;
import org.apache.camel.api.management.mbean.ManagedRouteMBean;
+import org.apache.camel.api.management.mbean.ManagedSendProcessorMBean;
import org.apache.camel.spi.RouteTopologyDumper;
import org.apache.camel.spi.RouteTopologyDumper.TopologyEdge;
+import org.apache.camel.spi.RouteTopologyDumper.TopologyExternalEndpoint;
import org.apache.camel.spi.RouteTopologyDumper.TopologyNode;
import org.apache.camel.spi.RouteTopologyDumper.TopologyResult;
import org.apache.camel.spi.annotations.DevConsole;
import org.apache.camel.support.PluginHelper;
import org.apache.camel.support.console.AbstractDevConsole;
+import org.apache.camel.util.URISupport;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
@@ -34,6 +39,7 @@ import org.apache.camel.util.json.JsonObject;
public class RouteTopologyDevConsole extends AbstractDevConsole {
private static final String METRIC = "metric";
+ private static final String EXTERNAL = "external";
public RouteTopologyDevConsole() {
super("camel", "route-topology", "Route Topology", "Route topology
showing inter-route connections");
@@ -46,6 +52,7 @@ public class RouteTopologyDevConsole extends
AbstractDevConsole {
return "";
}
TopologyResult result = dumper.dumpTopology(getCamelContext());
+ boolean external = "true".equals(options.get(EXTERNAL));
StringBuilder sb = new StringBuilder();
sb.append(String.format("Route Topology (%d routes, %d
connections)%n%n",
@@ -61,6 +68,15 @@ public class RouteTopologyDevConsole extends
AbstractDevConsole {
}
}
}
+
+ if (external && !result.externalEndpoints().isEmpty()) {
+ sb.append(String.format("%nExternal Endpoints:%n"));
+ for (TopologyExternalEndpoint ep : result.externalEndpoints()) {
+ sb.append(String.format(" [%s] %s (%s) route=%s%n",
+ ep.direction(), ep.uri(), ep.scheme(), ep.routeId()));
+ }
+ }
+
return sb.toString();
}
@@ -74,6 +90,7 @@ public class RouteTopologyDevConsole extends
AbstractDevConsole {
TopologyResult result = dumper.dumpTopology(getCamelContext());
boolean metric = "true".equals(options.get(METRIC));
+ boolean external = "true".equals(options.get(EXTERNAL));
ManagedCamelContext mcc = metric
?
getCamelContext().getCamelContextExtension().getContextPlugin(ManagedCamelContext.class)
: null;
@@ -112,7 +129,105 @@ public class RouteTopologyDevConsole extends
AbstractDevConsole {
}
root.put("edges", edgesArr);
+ if (external && !result.externalEndpoints().isEmpty()) {
+ // Collect per-endpoint metrics for producers (direction=out)
+ Map<String, long[]> endpointMetrics = collectEndpointMetrics(mcc,
result);
+
+ JsonArray extArr = new JsonArray();
+ for (TopologyExternalEndpoint ep : result.externalEndpoints()) {
+ JsonObject jo = new JsonObject();
+ jo.put("id", ep.id());
+ jo.put("uri", ep.uri());
+ jo.put("scheme", ep.scheme());
+ jo.put("direction", ep.direction());
+ jo.put("routeId", ep.routeId());
+
+ if (mcc != null) {
+ if ("in".equals(ep.direction())) {
+ // Consumer: use route-level metrics (route has
exactly 1 consumer)
+ ManagedRouteMBean mrb =
mcc.getManagedRoute(ep.routeId());
+ if (mrb != null) {
+ jo.put("exchangesTotal", mrb.getExchangesTotal());
+ jo.put("exchangesFailed",
mrb.getExchangesFailed());
+ }
+ } else {
+ // Producer: use processor-level metrics
+ String key = ep.routeId() + "|" + ep.uri();
+ long[] stats = endpointMetrics.get(key);
+ if (stats != null) {
+ jo.put("exchangesTotal", stats[0]);
+ jo.put("exchangesFailed", stats[1]);
+ }
+ }
+ }
+
+ extArr.add(jo);
+ }
+ root.put("externalEndpoints", extArr);
+ }
+
return root;
}
+ /**
+ * Collects per-endpoint metrics for producer endpoints by iterating
managed send processors. Returns a map keyed by
+ * "routeId|normalizedUri" with value [exchangesTotal, exchangesFailed].
+ */
+ private Map<String, long[]> collectEndpointMetrics(ManagedCamelContext
mcc, TopologyResult result) {
+ Map<String, long[]> metrics = new HashMap<>();
+ if (mcc == null) {
+ return metrics;
+ }
+ for (TopologyExternalEndpoint ep : result.externalEndpoints()) {
+ if (!"out".equals(ep.direction())) {
+ continue;
+ }
+ String epUri = stripDoubleSlash(URISupport.stripQuery(ep.uri()));
+ ManagedRouteMBean mrb = mcc.getManagedRoute(ep.routeId());
+ if (mrb == null) {
+ continue;
+ }
+ Collection<String> ids;
+ try {
+ ids = mrb.processorIds();
+ } catch (Exception e) {
+ continue;
+ }
+ for (String pid : ids) {
+ try {
+ ManagedSendProcessorMBean sp =
mcc.getManagedProcessor(pid, ManagedSendProcessorMBean.class);
+ if (sp == null) {
+ continue;
+ }
+ String dest = sp.getDestination();
+ if (dest == null) {
+ continue;
+ }
+ dest = stripDoubleSlash(URISupport.stripQuery(dest));
+ if (epUri.equals(dest)) {
+ String key = ep.routeId() + "|" + ep.uri();
+ long[] existing = metrics.get(key);
+ if (existing != null) {
+ existing[0] += sp.getExchangesTotal();
+ existing[1] += sp.getExchangesFailed();
+ } else {
+ metrics.put(key, new long[] {
sp.getExchangesTotal(), sp.getExchangesFailed() });
+ }
+ }
+ } catch (Exception e) {
+ // skip this processor
+ }
+ }
+ }
+ return metrics;
+ }
+
+ private static String stripDoubleSlash(String uri) {
+ int idx = uri.indexOf("://");
+ if (idx > 0) {
+ return uri.substring(0, idx + 1) + uri.substring(idx + 3);
+ }
+ return uri;
+ }
+
}
diff --git
a/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultRouteTopologyDumper.java
b/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultRouteTopologyDumper.java
index 246095167a0f..ea855a05acf9 100644
---
a/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultRouteTopologyDumper.java
+++
b/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultRouteTopologyDumper.java
@@ -19,11 +19,13 @@ package org.apache.camel.impl;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.camel.CamelContext;
+import org.apache.camel.Endpoint;
import org.apache.camel.model.EndpointRequiredDefinition;
import org.apache.camel.model.Model;
import org.apache.camel.model.ProcessorDefinitionHelper;
@@ -79,7 +81,87 @@ public class DefaultRouteTopologyDumper implements
RouteTopologyDumper {
}
}
- return new TopologyResult(nodes, edges);
+ // Compute external endpoints (remote systems that routes communicate
with)
+ List<TopologyExternalEndpoint> externalEndpoints =
computeExternalEndpoints(context, routeDefs, inputUriToRouteIds);
+
+ return new TopologyResult(nodes, edges, externalEndpoints);
+ }
+
+ private List<TopologyExternalEndpoint> computeExternalEndpoints(
+ CamelContext context, List<RouteDefinition> routeDefs,
+ Map<String, List<String>> inputUriToRouteIds) {
+
+ // Build scheme -> isRemote map from endpoint registry
+ // Skip stub endpoints — they mask the real component's remote status
+ Map<String, Boolean> schemeRemoteMap = new HashMap<>();
+ for (Endpoint ep : context.getEndpoints()) {
+ if (isStubEndpoint(ep)) {
+ continue;
+ }
+ String scheme = extractScheme(ep.getEndpointUri());
+ schemeRemoteMap.putIfAbsent(scheme, ep.isRemote());
+ }
+
+ // Collect all output URIs to determine which "from" endpoints are
truly external
+ Set<String> allOutputUris = new HashSet<>();
+ for (RouteDefinition rd : routeDefs) {
+ Collection<EndpointRequiredDefinition> outputs
+ = ProcessorDefinitionHelper.filterTypeInOutputs(
+ rd.getOutputs(), EndpointRequiredDefinition.class);
+ for (EndpointRequiredDefinition erd : outputs) {
+ allOutputUris.add(URISupport.stripQuery(erd.getEndpointUri()));
+ }
+ }
+
+ List<TopologyExternalEndpoint> externalEndpoints = new ArrayList<>();
+ Set<String> seenOutgoing = new HashSet<>();
+
+ for (RouteDefinition rd : routeDefs) {
+ String routeId = rd.getRouteId();
+
+ // Consumer (direction=in): only if no route sends to this URI
(truly from outside Camel)
+ String inputUri =
URISupport.stripQuery(rd.getInput().getEndpointUri());
+ String inputScheme = extractScheme(inputUri);
+ if (isRemoteScheme(inputScheme, schemeRemoteMap) &&
!allOutputUris.contains(inputUri)) {
+ externalEndpoints.add(
+ new TopologyExternalEndpoint("in-" + routeId,
inputUri, inputScheme, "in", routeId));
+ }
+
+ // Producers (direction=out): only if no route consumes from this
URI (truly leaving Camel)
+ Collection<EndpointRequiredDefinition> outputs
+ = ProcessorDefinitionHelper.filterTypeInOutputs(
+ rd.getOutputs(), EndpointRequiredDefinition.class);
+
+ int outIdx = 0;
+ for (EndpointRequiredDefinition erd : outputs) {
+ String outputUri = URISupport.stripQuery(erd.getEndpointUri());
+ String outputScheme = extractScheme(outputUri);
+ if (isRemoteScheme(outputScheme, schemeRemoteMap) &&
!inputUriToRouteIds.containsKey(outputUri)) {
+ String dedupeKey = routeId + "|" + outputUri;
+ if (seenOutgoing.add(dedupeKey)) {
+ externalEndpoints.add(
+ new TopologyExternalEndpoint(
+ "out-" + routeId + "-" + outIdx,
outputUri, outputScheme, "out", routeId));
+ outIdx++;
+ }
+ }
+ }
+ }
+
+ return externalEndpoints;
+ }
+
+ private boolean isRemoteScheme(String scheme, Map<String, Boolean>
schemeRemoteMap) {
+ Boolean remote = schemeRemoteMap.get(scheme);
+ if (remote != null) {
+ return remote;
+ }
+ // Fallback: internal and trigger schemes are not remote
+ return !INTERNAL_SCHEMES.contains(scheme) &&
!TRIGGER_SCHEMES.contains(scheme);
+ }
+
+ private static boolean isStubEndpoint(Endpoint ep) {
+ return "StubEndpoint".equals(ep.getClass().getSimpleName());
}
private static String extractScheme(String uri) {
diff --git
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-topology.adoc
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-topology.adoc
index b8d3f03e70f8..a18222220d2f 100644
---
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-topology.adoc
+++
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-topology.adoc
@@ -21,6 +21,7 @@ camel cmd route-topology [options]
| Option | Description | Default | Type
| `--box-width` | Width of diagram node boxes | 180 | int
| `--description` | Prefer route description over route id in node labels | |
boolean
+| `--external` | Include external systems (consumers at top, producers at
bottom) | | boolean
| `--font-size` | Font size in logical pixels for node text | 12 | int
| `--json` | Output in JSON Format | | boolean
| `--metric` | Whether to include live metrics | true | boolean
diff --git
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
index 5bf7dc0e6da6..91296898a42a 100644
---
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
+++
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
@@ -755,7 +755,9 @@ public class LocalCliConnector extends ServiceSupport
implements CliConnector, C
.resolveById("route-topology");
if (dc != null) {
String metric = root.getStringOrDefault("metric", "false");
- JsonObject json = (JsonObject) dc.call(DevConsole.MediaType.JSON,
Map.of("metric", metric));
+ String external = root.getStringOrDefault("external", "false");
+ JsonObject json
+ = (JsonObject) dc.call(DevConsole.MediaType.JSON,
Map.of("metric", metric, "external", external));
LOG.trace("Updating output file: {}", outputFile);
IOHelper.writeText(json.toJson(), outputFile);
} else {
diff --git a/dsl/camel-jbang/camel-jbang-core/pom.xml
b/dsl/camel-jbang/camel-jbang-core/pom.xml
index b0b9f0c33b89..1b0e2716e499 100644
--- a/dsl/camel-jbang/camel-jbang-core/pom.xml
+++ b/dsl/camel-jbang/camel-jbang-core/pom.xml
@@ -308,6 +308,10 @@
<sync-example name="rest-api"
file="README.md"/>
<sync-example name="rest-api"
file="application.properties"/>
<sync-example name="rest-api"
file="rest-api.camel.yaml"/>
+ <!-- route-topology -->
+ <sync-example name="route-topology"
file="README.md"/>
+ <sync-example name="route-topology"
file="application.properties"/>
+ <sync-example name="route-topology"
file="route-topology.camel.yaml"/>
<!-- routes -->
<sync-example name="routes"
file="Greeter.java"/>
<sync-example name="routes"
file="README.md"/>
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
index fd70451710c6..b573203b8850 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
+++
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
@@ -3,7 +3,7 @@
{ "name": "ask", "fullName": "ask", "description": "Ask a question about a
running Camel application using AI", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.Ask", "options": [ { "names":
"--api-key", "description": "API key. Also reads ANTHROPIC_API_KEY,
OPENAI_API_KEY, or LLM_API_KEY env vars", "javaType": "java.lang.String",
"type": "string" }, { "names": "--api-type", "description": "API type:
'ollama', 'openai', or 'anthropic'", "javaType": "LlmClient.ApiType", "type"
[...]
{ "name": "bind", "fullName": "bind", "description": "DEPRECATED: Bind
source and sink Kamelets as a new Camel integration", "deprecated": true,
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.bind.Bind", "options":
[ { "names": "--error-handler", "description": "Add error handler
(none|log|sink:<endpoint>). Sink endpoints are expected in the format
[[apigroup\/]version:]kind:[namespace\/]name, plain Camel URIs or Kamelet
name.", "javaType": "java.lang.String", "type": "stri [...]
{ "name": "catalog", "fullName": "catalog", "description": "List artifacts
from Camel Catalog", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.catalog.CatalogCommand", "options": [
{ "names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name":
"component", "fullName": "catalog component", "description": "List components
from the Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.co [...]
- { "name": "cmd", "fullName": "cmd", "description": "Performs commands in
the running Camel integrations, such as start\/stop route, or change logging
levels.", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ {
"names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name":
"browse", "fullName": "cmd browse", "description": "Browse pending messages on
endpoints [...]
+ { "name": "cmd", "fullName": "cmd", "description": "Performs commands in
the running Camel integrations, such as start\/stop route, or change logging
levels.", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ {
"names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name":
"browse", "fullName": "cmd browse", "description": "Browse pending messages on
endpoints [...]
{ "name": "completion", "fullName": "completion", "description": "Generate
completion script for bash\/zsh", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.Complete", "options": [ { "names":
"-h,--help", "description": "Display the help and sub-commands", "javaType":
"boolean", "type": "boolean" } ] },
{ "name": "config", "fullName": "config", "description": "Get and set user
configuration values", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.config.ConfigCommand", "options": [ {
"names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "get",
"fullName": "config get", "description": "Display user configuration value",
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.config. [...]
{ "name": "debug", "fullName": "debug", "description": "Debug local Camel
integration", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Debug",
"options": [ { "names": "--ago", "description": "Use ago instead of yyyy-MM-dd
HH:mm:ss in timestamp.", "javaType": "boolean", "type": "boolean" }, { "names":
"--background", "description": "Run in the background", "defaultValue":
"false", "javaType": "boolean", "type": "boolean" }, { "names":
"--background-wait", "description": "To [...]
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteTopologyAction.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteTopologyAction.java
index db9bb52aa869..7b8dbafa3d6d 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteTopologyAction.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteTopologyAction.java
@@ -88,6 +88,10 @@ public class CamelRouteTopologyAction extends
ActionBaseCommand {
description = "Whether to include live metrics")
boolean metric;
+ @CommandLine.Option(names = { "--external" },
+ description = "Include external systems (consumers at
top, producers at bottom)")
+ boolean external;
+
public CamelRouteTopologyAction(CamelJBangMain main) {
super(main);
}
@@ -108,6 +112,7 @@ public class CamelRouteTopologyAction extends
ActionBaseCommand {
long pid = pids.get(0);
Path outputFile = prepareAction(Long.toString(pid), "route-topology",
root -> {
root.put("metric", String.valueOf(metric));
+ root.put("external", String.valueOf(external));
});
JsonObject jo = getJsonObject(outputFile);
@@ -167,6 +172,7 @@ public class CamelRouteTopologyAction extends
ActionBaseCommand {
private void printTextDiagram(JsonObject jo) throws Exception {
List<TopologyNodeInfo> nodes = TopologyHelper.parseNodes(jo);
List<TopologyEdgeInfo> edges = TopologyHelper.parseEdges(jo);
+ TopologyHelper.addExternalEndpoints(nodes, edges, jo);
TopologyLayoutEngine engine = new TopologyLayoutEngine(boxWidth);
TopologyLayoutResult result = engine.layout(nodes, edges);
@@ -195,6 +201,7 @@ public class CamelRouteTopologyAction extends
ActionBaseCommand {
List<TopologyNodeInfo> nodes = TopologyHelper.parseNodes(jo);
List<TopologyEdgeInfo> edges = TopologyHelper.parseEdges(jo);
+ TopologyHelper.addExternalEndpoints(nodes, edges, jo);
TopologyLayoutEngine engine = new TopologyLayoutEngine(boxWidth);
TopologyLayoutResult result = engine.layout(nodes, edges);
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/camel-jbang-example-catalog.json
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/camel-jbang-example-catalog.json
index 10c4a6e65692..1d5361fa33f6 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/camel-jbang-example-catalog.json
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/camel-jbang-example-catalog.json
@@ -372,6 +372,29 @@
"rest-api.camel.yaml"
]
},
+ {
+ "name": "route-topology",
+ "title": "Route Topology",
+ "description": "Demonstrates inter-route topology with triggers,
shared routes, and external systems",
+ "level": "intermediate",
+ "tags": [
+ "topology",
+ "direct",
+ "kafka",
+ "timer"
+ ],
+ "bundled": true,
+ "requiresDocker": false,
+ "hasCitrusTests": false,
+ "files": [
+ "README.md",
+ "application.properties",
+ "route-topology.camel.yaml"
+ ],
+ "infraServices": [
+ "kafka"
+ ]
+ },
{
"name": "routes",
"title": "Routes",
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/route-topology/README.md
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/route-topology/README.md
new file mode 100644
index 000000000000..194f09d6ca33
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/route-topology/README.md
@@ -0,0 +1,61 @@
+## Route Topology
+
+This example demonstrates inter-route topology in an order processing system.
+It showcases how multiple routes connect through shared endpoints — both
internal (direct)
+and external (kafka) — making it a good example for the `route-topology`
command.
+
+### How to run
+
+ camel run route-topology.camel.yaml
+
+You can use `--stub` to run without a Kafka broker installed.
+This replaces Kafka with an internal in-memory queue, so the routes still
connect and messages flow end-to-end:
+
+ camel run route-topology.camel.yaml --stub=kafka
+
+### View the route topology
+
+ camel cmd route-topology
+
+View as a Unicode diagram with live metrics and route descriptions:
+
+ camel cmd route-topology --theme=unicode --metric --description
+
+Sample output:
+
+```
+ ┌──────────────────────┐ ┌──────────────────────┐
+ │ Generate Orders │ │ Order REST API │
+ │ 54 │ └──────────────────────┘
+ └──────────────────────┘
+ │ │
+ │ ┬─────────────┘
+ └─────────────│
+ ▼
+ ┌──────────────────────┐
+ │ Process Order │
+ │ 54 │
+ └──────────────────────┘
+ │
+ ┬─────────────┴─────────────┬
+ ▼ ▼
+ ┌──────────────────────┐ ┌──────────────────────┐
+ │ Dispatch Order │ │ Validate Order │
+ │ 54 │ │ 54 │
+ └──────────────────────┘ └──────────────────────┘
+ │
+ └───────────────────────────┬
+ ▼ ▼
+ ┌──────────────────────┐ ┌──────────────────────┐
+ │ Fulfill Order │ │ Send Notification │
+ │ 54 │ │ 54 │
+ └──────────────────────┘ └──────────────────────┘
+```
+
+Save as PNG image:
+
+ camel cmd route-topology --theme=dark --output=topology.png
+
+### View as JSON
+
+ camel cmd route-topology --json
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/route-topology/application.properties
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/route-topology/application.properties
new file mode 100644
index 000000000000..68fb88547708
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/route-topology/application.properties
@@ -0,0 +1,3 @@
+# Kafka defaults to localhost:9092
+# To use a different broker, uncomment and adjust:
+camel.component.kafka.brokers=localhost:9092
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/route-topology/route-topology.camel.yaml
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/route-topology/route-topology.camel.yaml
new file mode 100644
index 000000000000..a89d27b967de
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/route-topology/route-topology.camel.yaml
@@ -0,0 +1,97 @@
+# Order Processing System
+# This example demonstrates inter-route topology with triggers, shared routes,
and external systems.
+# Use "camel cmd route-topology" to visualize how the routes connect to each
other.
+
+# Trigger: generates a new order every 5 seconds
+- route:
+ id: order-generator
+ description: Generate Orders
+ from:
+ uri: timer
+ parameters:
+ timerName: orders
+ period: 5000
+ steps:
+ - setBody:
+ expression:
+ simple:
+ expression: '{"orderId": "${exchangeId}", "item": "Camel
T-Shirt", "quantity": 1}'
+ - to:
+ uri: direct:process-order
+
+# HTTP entry point: receives orders from external clients
+- route:
+ id: order-api
+ description: Order REST API
+ from:
+ uri: platform-http
+ parameters:
+ path: /api/orders
+ httpMethodRestrict: POST
+ steps:
+ - to:
+ uri: direct:process-order
+
+# Shared route: validates and publishes orders (used by both order-generator
and order-api)
+- route:
+ id: process-order
+ description: Process Order
+ from:
+ uri: direct:process-order
+ steps:
+ - to:
+ uri: direct:validate-order
+ - log:
+ message: "Processing order: ${body}"
+ - to:
+ uri: kafka:orders
+
+# Validation: checks order contents
+- route:
+ id: validate-order
+ description: Validate Order
+ from:
+ uri: direct:validate-order
+ steps:
+ - log:
+ message: "Validating order: ${body}"
+
+# Kafka consumer: picks up orders and fans out to fulfillment and notifications
+- route:
+ id: order-dispatcher
+ description: Dispatch Order
+ from:
+ uri: kafka:orders
+ steps:
+ - log:
+ message: "Dispatching order: ${body}"
+ - multicast:
+ steps:
+ - to:
+ uri: kafka:fulfillment
+ - to:
+ uri: kafka:notifications
+
+# Kafka consumer: handles fulfillment and reports to warehouse
+- route:
+ id: fulfillment
+ description: Fulfill Order
+ from:
+ uri: kafka:fulfillment
+ steps:
+ - log:
+ message: "Fulfilling order: ${body}"
+ - to:
+ uri: kafka:warehouse-shipments
+
+# Kafka consumer: sends notifications via external email service
+- route:
+ id: notification
+ description: Send Notification
+ from:
+ uri: kafka:notifications
+ steps:
+ - log:
+ message: "Sending notification for: ${body}"
+ - to:
+ uri: kafka:email-outbox
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
index 9c660417f9f9..1fab0476680f 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
@@ -35,6 +35,7 @@ import java.util.stream.Collectors;
import dev.tamboui.layout.Rect;
import dev.tamboui.markdown.MarkdownView;
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;
@@ -847,9 +848,18 @@ class ActionsPopup {
private void renderDocViewer(Frame frame, Rect area) {
frame.renderWidget(Clear.INSTANCE, area);
Rect popup = new Rect(area.left() + 2, area.top() + 1, area.width() -
4, area.height() - 2);
+ Title title;
+ if (docTitle != null && docTitle.startsWith("Failed:")) {
+ String rest = docTitle.substring("Failed:".length());
+ title = Title.from(Line.from(
+ Span.styled(" Failed:",
Style.EMPTY.fg(Color.LIGHT_RED).bold()),
+ Span.raw(rest + " ")));
+ } else {
+ title = Title.from(" " + docTitle + " ");
+ }
Block block = Block.builder()
.borderType(BorderType.ROUNDED)
- .title(" " + docTitle + " ")
+ .title(title)
.titleBottom(Title.from(Line.from(
Span.styled(" ↑↓", MonitorContext.HINT_KEY_STYLE),
Span.raw(" scroll │"),
Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE),
Span.raw(" back "))))
@@ -861,9 +871,13 @@ class ActionsPopup {
int totalLines = docLines.size();
int clampedScroll = Math.min(docScroll, Math.max(0, totalLines -
visibleLines));
int end = Math.min(clampedScroll + visibleLines, totalLines);
- List<Line> visible = docLines.subList(clampedScroll, end);
+ List<Line> visible = new
ArrayList<>(docLines.subList(clampedScroll, end));
+ while (visible.size() < visibleLines) {
+ visible.add(Line.from(""));
+ }
frame.renderWidget(
-
Paragraph.builder().text(Text.from(visible.toArray(Line[]::new))).build(),
+
Paragraph.builder().text(Text.from(visible.toArray(Line[]::new)))
+ .overflow(Overflow.CLIP).build(),
inner);
} else {
MarkdownView view = MarkdownView.builder()
@@ -1333,16 +1347,19 @@ class ActionsPopup {
displayName = exampleName;
}
List<String> extraArgs = runOptionsForm.buildArgs();
+ boolean stub = runOptionsForm.isStubMode();
runOptionsForm.close();
- List<String> missing = findMissingInfraServices(selectedExample);
- if (!missing.isEmpty()) {
- if (!isContainerRuntimeAvailable()) {
- setNotification("Docker/Podman required for infra services.
Run Doctor for details", true);
+ if (!stub) {
+ List<String> missing = findMissingInfraServices(selectedExample);
+ if (!missing.isEmpty()) {
+ if (!isContainerRuntimeAvailable()) {
+ setNotification("Docker/Podman required for infra
services. Run Doctor for details", true);
+ return;
+ }
+ startMissingInfraAndDeferExample(missing, exampleName,
displayName, extraArgs);
return;
}
- startMissingInfraAndDeferExample(missing, exampleName,
displayName, extraArgs);
- return;
}
doLaunchExample(exampleName, displayName, extraArgs);
@@ -1916,11 +1933,7 @@ class ActionsPopup {
launchNotificationError = false;
launchNotificationExpiry = now + 5000;
} else {
- String detail = readFirstLine(pl.outputFile());
- launchNotification = "Failed: " + pl.name()
- + (detail != null ? " - " + detail :
"");
- launchNotificationError = true;
- launchNotificationExpiry = now + 10000;
+ showFailureLog(pl.name(), pl.outputFile());
}
it.remove();
} else if (now - pl.startTime() > 8000) {
@@ -1932,6 +1945,30 @@ class ActionsPopup {
}
}
+ private void showFailureLog(String name, Path logFile) {
+ List<String> logLines = readAllLines(logFile);
+ if (logLines.isEmpty()) {
+ setNotification("Failed: " + name + " (no output)", true);
+ return;
+ }
+ docTitle = "Failed: " + name;
+ docContent = null;
+ docLines = logLines.stream()
+ .map(line -> TuiHelper.ansiToLine(line.replace("\t", "
"), 0))
+ .collect(Collectors.toList());
+ docScroll = 0;
+ showDocViewer = true;
+ docViewerFromExampleBrowser = false;
+ }
+
+ private static List<String> readAllLines(Path file) {
+ try {
+ return Files.readAllLines(file);
+ } catch (IOException e) {
+ return List.of();
+ }
+ }
+
// ---- Utilities ----
private static String readFirstLine(Path file) {
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 7d67c3475565..43985f4db3fc 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
@@ -431,8 +431,8 @@ class DiagramSupport {
}
void loadTopologyDiagramInBackground(
- MonitorContext ctx, String pid, boolean textMode, boolean metrics)
{
- JsonObject jo = requestRouteTopology(ctx, pid);
+ MonitorContext ctx, String pid, boolean textMode, boolean metrics,
boolean external) {
+ JsonObject jo = requestRouteTopology(ctx, pid, external);
if (jo == null) {
applyResult(ctx, List.of("(No response from integration)"), null,
null, null);
return;
@@ -440,6 +440,9 @@ class DiagramSupport {
List<TopologyNodeInfo> nodes = TopologyHelper.parseNodes(jo);
List<TopologyEdgeInfo> edges = TopologyHelper.parseEdges(jo);
+ if (external) {
+ TopologyHelper.addExternalEndpoints(nodes, edges, jo);
+ }
if (nodes.isEmpty()) {
applyResult(ctx, List.of("(No routes in response)"), null, null,
null);
return;
@@ -473,7 +476,7 @@ class DiagramSupport {
case OK -> RouteDiagramAsciiRenderer.CounterType.OK;
case FAIL ->
RouteDiagramAsciiRenderer.CounterType.FAIL;
case TRIGGER ->
RouteDiagramAsciiRenderer.CounterType.HIGHLIGHT_SUCCESS;
- case EXTERNAL ->
RouteDiagramAsciiRenderer.CounterType.OK;
+ case EXTERNAL ->
RouteDiagramAsciiRenderer.CounterType.EXTERNAL;
};
positions.add(new RouteDiagramAsciiRenderer.CounterPos(
rowMapping[cp.row()], cp.col(), cp.length(),
mapped));
@@ -501,13 +504,16 @@ class DiagramSupport {
}
}
- private JsonObject requestRouteTopology(MonitorContext ctx, String pid) {
+ private JsonObject requestRouteTopology(MonitorContext ctx, String pid,
boolean external) {
Path outputFile = ctx.getOutputFile(pid);
PathUtils.deleteFile(outputFile);
JsonObject root = new JsonObject();
root.put("action", "route-topology");
root.put("metric", "true");
+ if (external) {
+ root.put("external", "true");
+ }
Path actionFile = ctx.getActionFile(pid);
PathUtils.writeTextSafely(root.toJson(), actionFile);
@@ -689,6 +695,7 @@ class DiagramSupport {
end = Math.min(end, text.length());
int colorFlag = switch (cp.type()) {
case OK, HIGHLIGHT_SUCCESS -> 1; // green
+ case EXTERNAL -> 3; // cyan
default -> 2; // red
};
counterRanges.add(new int[] { start, end, colorFlag });
@@ -737,7 +744,7 @@ class DiagramSupport {
spans.add(Span.styled(text.substring(pos, cr[0]),
Style.EMPTY.fg(defaultColor)));
}
int counterEnd = Math.min(cr[1], to);
- Color counterColor = cr[2] == 1 ? Color.GREEN :
Color.LIGHT_RED;
+ Color counterColor = cr[2] == 1 ? Color.GREEN : cr[2] == 3 ?
Color.CYAN : Color.LIGHT_RED;
spans.add(Span.styled(text.substring(cr[0], counterEnd),
Style.EMPTY.fg(counterColor).bold()));
pos = counterEnd;
} else {
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
index 0311dbad42ef..3c08ef6fbbee 100644
---
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
@@ -36,6 +36,7 @@ class DiagramTab implements MonitorTab {
private final MonitorContext ctx;
private final DiagramSupport diagram = new DiagramSupport();
private boolean diagramMetrics = true;
+ private boolean showExternal;
private boolean topologyMode = true;
private String drillDownRouteId;
@@ -61,6 +62,14 @@ class DiagramTab implements MonitorTab {
return true;
}
+ // Toggle external systems
+ if (diagram.isShowDiagram() && topologyMode &&
ke.isCharIgnoreCase('e')) {
+ showExternal = !showExternal;
+ diagram.endLoad();
+ reloadDiagram();
+ return true;
+ }
+
// Toggle description
if (diagram.isShowDiagram() && ke.isCharIgnoreCase('n')) {
diagram.setShowDescription(!diagram.isShowDescription());
@@ -151,6 +160,7 @@ class DiagramTab implements MonitorTab {
if (diagram.isShowDiagram()) {
diagram.renderFooterHints(spans);
hint(spans, "m", "metrics" + (diagramMetrics ? " [on]" : "
[off]"));
+ hint(spans, "e", "external" + (showExternal ? " [on]" : " [off]"));
hint(spans, "n", "description" + (diagram.isShowDescription() ? "
[on]" : " [off]"));
}
}
@@ -179,6 +189,7 @@ class DiagramTab implements MonitorTab {
String pid = ctx.selectedPid;
boolean showMetrics = diagramMetrics;
+ boolean external = showExternal;
if (showPlaceholder) {
diagram.setLoadingPlaceholder();
@@ -188,7 +199,7 @@ class DiagramTab implements MonitorTab {
try {
if (topologyMode) {
diagram.setTopologyMode(true);
- diagram.loadTopologyDiagramInBackground(ctx, pid, true,
showMetrics);
+ diagram.loadTopologyDiagramInBackground(ctx, pid, true,
showMetrics, external);
} else {
diagram.setTopologyMode(false);
diagram.loadRouteDiagramInBackground(ctx, pid, true,
drillDownRouteId, showMetrics);
@@ -244,9 +255,21 @@ class DiagramTab implements MonitorTab {
- **Red** number with `!` — failed exchanges
- Combined as `3748/12!` means 3748 ok and 12
failed
+ ## External Systems
+
+ When external systems are enabled, the diagram
shows a three-band layout:
+ - **Top band** — external consumers sending
messages INTO Camel
+ - **Middle band** — the Camel routes and their
internal connections
+ - **Bottom band** — external producers where
Camel sends messages OUT
+
+ External system boxes are drawn with dashed
borders to distinguish
+ them from route boxes. Dashed edges connect
routes to external systems.
+
## Keys
- `m` — toggle metrics on/off (default: on)
+ - `e` — toggle external systems on/off
(default: off)
+ - `n` — toggle description labels on/off
(default: off)
- `↑↓←→` — scroll diagram
- `PgUp/PgDn` — page scroll
- `Home/End` — top/end
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
index c481f6b030cd..12b4a05df719 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
@@ -49,7 +49,8 @@ class RunOptionsForm {
private static final int ROW_DEV = 3;
private static final int ROW_OBSERVE = 4;
private static final int ROW_TRACE = 5;
- private static final int ROW_COUNT = 6;
+ private static final int ROW_STUB = 6;
+ private static final int ROW_COUNT = 7;
private boolean visible;
private int page;
@@ -68,6 +69,7 @@ class RunOptionsForm {
private boolean devMode;
private boolean observe;
private boolean backlogTrace;
+ private boolean stubMode;
private String exampleTitle;
@@ -92,6 +94,7 @@ class RunOptionsForm {
devMode = dev;
observe = false;
backlogTrace = false;
+ stubMode = false;
selectedRow = ROW_NAME;
page = PAGE_OPTIONS;
selectedProperty = 0;
@@ -108,6 +111,10 @@ class RunOptionsForm {
return nameInput != null ? nameInput.text().trim() : "";
}
+ boolean isStubMode() {
+ return stubMode;
+ }
+
boolean handleKeyEvent(KeyEvent ke) {
if (!visible) {
return false;
@@ -182,6 +189,9 @@ class RunOptionsForm {
if (backlogTrace) {
args.add("--backlog-trace");
}
+ if (stubMode) {
+ args.add("--stub=all");
+ }
if (properties != null) {
for (PropertyEntry pe : properties) {
String current = pe.valueInput().text();
@@ -205,7 +215,7 @@ class RunOptionsForm {
return true;
}
if (ke.isDown()) {
- if (selectedRow == ROW_TRACE && hasProperties()) {
+ if (selectedRow == ROW_STUB && hasProperties()) {
page = PAGE_PROPERTIES;
selectedProperty = 0;
} else {
@@ -214,7 +224,7 @@ class RunOptionsForm {
return true;
}
if (ke.isFocusNext()) {
- if (selectedRow == ROW_TRACE && hasProperties()) {
+ if (selectedRow == ROW_STUB && hasProperties()) {
page = PAGE_PROPERTIES;
selectedProperty = 0;
} else {
@@ -226,7 +236,7 @@ class RunOptionsForm {
selectedRow = (selectedRow - 1 + ROW_COUNT) % ROW_COUNT;
return true;
}
- if (ke.isRight() && hasProperties() && selectedRow >= ROW_DEV) {
+ if (ke.isRight() && hasProperties() && selectedRow >= ROW_STUB) {
page = PAGE_PROPERTIES;
selectedProperty = 0;
return true;
@@ -250,6 +260,7 @@ class RunOptionsForm {
case ROW_DEV -> devMode = !devMode;
case ROW_OBSERVE -> observe = !observe;
case ROW_TRACE -> backlogTrace = !backlogTrace;
+ case ROW_STUB -> stubMode = !stubMode;
}
return true;
}
@@ -276,7 +287,7 @@ class RunOptionsForm {
editingKey = false;
if (selectedProperty == 0) {
page = PAGE_OPTIONS;
- selectedRow = ROW_TRACE;
+ selectedRow = ROW_STUB;
} else {
selectedProperty--;
}
@@ -306,7 +317,7 @@ class RunOptionsForm {
} else if (selectedProperty == 0) {
editingKey = false;
page = PAGE_OPTIONS;
- selectedRow = ROW_TRACE;
+ selectedRow = ROW_STUB;
} else {
editingKey = false;
selectedProperty--;
@@ -322,7 +333,7 @@ class RunOptionsForm {
return true;
}
page = PAGE_OPTIONS;
- selectedRow = ROW_TRACE;
+ selectedRow = ROW_STUB;
editingKey = false;
return true;
}
@@ -337,7 +348,7 @@ class RunOptionsForm {
}
if (properties.isEmpty()) {
page = PAGE_OPTIONS;
- selectedRow = ROW_TRACE;
+ selectedRow = ROW_STUB;
}
return true;
}
@@ -365,7 +376,7 @@ class RunOptionsForm {
private void renderOptionsPage(Frame frame, Rect area) {
int popupW = Math.min(56, area.width() - 4);
- int popupH = 10;
+ int popupH = 11;
int x = area.left() + Math.max(0, (area.width() - popupW) / 2);
int y = area.top() + Math.max(0, (area.height() - popupH) / 2);
Rect popup = new Rect(x, y, Math.min(popupW, area.width()),
Math.min(popupH, area.height()));
@@ -427,6 +438,9 @@ class RunOptionsForm {
rowY++;
renderCheckbox(frame, innerX, rowY, innerW, "Backlog trace",
backlogTrace, selectedRow == ROW_TRACE);
+ rowY++;
+
+ renderCheckbox(frame, innerX, rowY, innerW, "Stub (no Docker needed)",
stubMode, selectedRow == ROW_STUB);
}
private void renderPropertiesPage(Frame frame, Rect area) {
diff --git
a/dsl/camel-kamelet-main/src/main/java/org/apache/camel/main/download/DependencyDownloaderComponentResolver.java
b/dsl/camel-kamelet-main/src/main/java/org/apache/camel/main/download/DependencyDownloaderComponentResolver.java
index 3dc0847b11f0..acb48a2350ea 100644
---
a/dsl/camel-kamelet-main/src/main/java/org/apache/camel/main/download/DependencyDownloaderComponentResolver.java
+++
b/dsl/camel-kamelet-main/src/main/java/org/apache/camel/main/download/DependencyDownloaderComponentResolver.java
@@ -37,7 +37,8 @@ import org.apache.camel.tooling.model.OtherModel;
public final class DependencyDownloaderComponentResolver extends
DefaultComponentResolver {
private static final String[] ACCEPTED_STUB_NAMES = {
- "stub", "bean", "class", "direct", "kamelet", "log",
"platform-http", "rest", "seda"
+ "stub", "bean", "class", "cron", "direct", "kamelet", "log",
"platform-http", "rest", "scheduler", "seda",
+ "timer"
};
private static final String[] ACCEPTED_TRANSFORM_NAMES = {
diff --git
a/dsl/camel-kamelet-main/src/main/java/org/apache/camel/main/download/ExportTypeConverter.java
b/dsl/camel-kamelet-main/src/main/java/org/apache/camel/main/download/ExportTypeConverter.java
index a8ce40c6269b..a449c0692c03 100644
---
a/dsl/camel-kamelet-main/src/main/java/org/apache/camel/main/download/ExportTypeConverter.java
+++
b/dsl/camel-kamelet-main/src/main/java/org/apache/camel/main/download/ExportTypeConverter.java
@@ -16,6 +16,8 @@
*/
package org.apache.camel.main.download;
+import java.time.Duration;
+
import org.apache.camel.Exchange;
import org.apache.camel.TypeConversionException;
import org.apache.camel.converter.ObjectConverter;
@@ -47,6 +49,8 @@ public class ExportTypeConverter extends TypeConverterSupport
{
return (T) Short.valueOf("1");
} else if (type == byte.class || type == Byte.class) {
return (T) Byte.valueOf("0");
+ } else if (type == Duration.class) {
+ return (T) Duration.ofMillis(1);
} else if (type == String.class) {
return (T) PropertyConfigurerSupport.MAGIC_VALUE;
}
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/DummyTypeConverter.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/DummyTypeConverter.java
index d84deef9613d..ae91557b4e86 100644
---
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/DummyTypeConverter.java
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/DummyTypeConverter.java
@@ -16,6 +16,8 @@
*/
package org.apache.camel.dsl.yaml.validator;
+import java.time.Duration;
+
import org.apache.camel.Exchange;
import org.apache.camel.TypeConversionException;
import org.apache.camel.converter.ObjectConverter;
@@ -47,6 +49,8 @@ public class DummyTypeConverter extends TypeConverterSupport {
return (T) Short.valueOf("1");
} else if (type == byte.class || type == Byte.class) {
return (T) Byte.valueOf("0");
+ } else if (type == Duration.class) {
+ return (T) Duration.ofMillis(1);
} else if (type == String.class) {
return (T) PropertyConfigurerSupport.MAGIC_VALUE;
}