This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch topology-external-endpoints
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 7aeb0286934598c5552bc3f656b979479128babd
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Jun 2 11:01:03 2026 +0200

    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]>
---
 .../apache/camel/diagram/RouteDiagramRenderer.java |   6 +
 .../camel/diagram/TopologyAsciiRenderer.java       |  20 ++-
 .../org/apache/camel/diagram/TopologyHelper.java   |  46 ++++++
 .../camel/diagram/TopologyImageRenderer.java       |  28 +++-
 .../apache/camel/diagram/TopologyLayoutEngine.java | 101 +++++++++---
 .../apache/camel/diagram/TopologyDiagramTest.java  | 173 +++++++++++++++++++++
 .../org/apache/camel/spi/RouteTopologyDumper.java  |  24 ++-
 .../impl/console/RouteTopologyDevConsole.java      |  27 ++++
 .../camel/impl/DefaultRouteTopologyDumper.java     |  66 +++++++-
 .../commands/action/CamelRouteTopologyAction.java  |   7 +
 10 files changed, 462 insertions(+), 36 deletions(-)

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..942fea5ff174 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
@@ -122,12 +122,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 +141,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);
@@ -145,7 +151,7 @@ public class TopologyAsciiRenderer {
             }
         }
 
-        if (metrics) {
+        if (metrics && !isExternalNode(node)) {
             if (node.exchangesTotal > 0 || node.exchangesFailed > 0) {
                 long ok = node.exchangesTotal - node.exchangesFailed;
                 StringBuilder sb = new StringBuilder();
@@ -208,7 +214,10 @@ public class TopologyAsciiRenderer {
             int textCol = col + 2 + Math.max(0, (innerWidth - text.length()) / 
2);
             drawText(grid, r, textCol, text);
 
-            if (metrics && i == lines.size() - 1 && node.exchangesTotal > 0) {
+            // Track counter positions for ANSI coloring
+            if (isExternalNode(node) && i == 0) {
+                counterPositions.add(new CounterPos(r, textCol, text.length(), 
CounterType.EXTERNAL));
+            } else if (metrics && !isExternalNode(node) && i == lines.size() - 
1 && node.exchangesTotal > 0) {
                 long ok = node.exchangesTotal - node.exchangesFailed;
                 if (ok > 0) {
                     String okStr = "" + ok;
@@ -291,6 +300,9 @@ public class TopologyAsciiRenderer {
     }
 
     private int boxHeight(TopologyLayoutNode node) {
+        if (isExternalNode(node)) {
+            return 3;
+        }
         int lines = 3; // routeId + from (2 lines reserved)
         if (metrics) {
             lines++;
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..4381666aae81 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,52 @@ 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;
+
+            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..f1fd29ec0a0f 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 = "external".equals(edge.connectionType);
+            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();
@@ -137,7 +150,10 @@ public class TopologyImageRenderer {
             // 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 +161,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);
@@ -163,8 +179,8 @@ public class TopologyImageRenderer {
                 g.setFont(font);
             }
 
-            // Metrics
-            if (metrics && node.exchangesTotal > 0) {
+            // Metrics (not shown for external nodes)
+            if (metrics && !isExternalNode(node) && node.exchangesTotal > 0) {
                 long ok = node.exchangesTotal - node.exchangesFailed;
                 Font metricsFont = font.deriveFont((float) (fontSizeScaled * 
0.75));
                 g.setFont(metricsFont);
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..a58984ae38ac 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,178 @@ 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 with external endpoints
+        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")));
+
+        // Consumers (external-in)
+        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("in-order-dispatcher", "kafka:orders", "external-in"));
+        edges.add(edge("in-order-dispatcher", "order-dispatcher", 
"kafka:orders", "external"));
+        nodes.add(node("in-fulfillment", "kafka:fulfillment", "external-in"));
+        edges.add(edge("in-fulfillment", "fulfillment", "kafka:fulfillment", 
"external"));
+        nodes.add(node("in-notification", "kafka:notifications", 
"external-in"));
+        edges.add(edge("in-notification", "notification", 
"kafka:notifications", "external"));
+
+        // Producers (external-out)
+        nodes.add(node("out-process-order-0", "kafka:orders", "external-out"));
+        edges.add(edge("process-order", "out-process-order-0", "kafka:orders", 
"external"));
+        nodes.add(node("out-order-dispatcher-0", "kafka:fulfillment", 
"external-out"));
+        edges.add(edge("order-dispatcher", "out-order-dispatcher-0", 
"kafka:fulfillment", "external"));
+        nodes.add(node("out-order-dispatcher-1", "kafka:notifications", 
"external-out"));
+        edges.add(edge("order-dispatcher", "out-order-dispatcher-1", 
"kafka:notifications", "external"));
+
+        TopologyLayoutEngine engine = new TopologyLayoutEngine();
+        TopologyLayoutResult result = engine.layout(nodes, edges);
+
+        // 7 routes + 4 consumers + 3 producers = 14 nodes
+        assertEquals(14, result.nodes.size());
+
+        // Verify three-band ordering
+        TopologyLayoutNode extIn = findNode(result, "in-order-api");
+        TopologyLayoutNode route = findNode(result, "process-order");
+        TopologyLayoutNode extOut = findNode(result, "out-process-order-0");
+
+        assertEquals(0, extIn.layer, "External-in should be at layer 0");
+        assertTrue(route.layer > extIn.layer, "Routes should be below 
external-in band");
+        assertTrue(extOut.layer > route.layer, "External-out should be below 
route 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..7714f174dde7 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
@@ -22,6 +22,7 @@ import org.apache.camel.api.management.ManagedCamelContext;
 import org.apache.camel.api.management.mbean.ManagedRouteMBean;
 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;
@@ -34,6 +35,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 +48,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 +64,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 +86,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,6 +125,20 @@ public class RouteTopologyDevConsole extends 
AbstractDevConsole {
         }
         root.put("edges", edgesArr);
 
+        if (external && !result.externalEndpoints().isEmpty()) {
+            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());
+                extArr.add(jo);
+            }
+            root.put("externalEndpoints", extArr);
+        }
+
         return root;
     }
 
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..1651465effef 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,69 @@ 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
+        Map<String, Boolean> schemeRemoteMap = new HashMap<>();
+        for (Endpoint ep : context.getEndpoints()) {
+            String scheme = extractScheme(ep.getEndpointUri());
+            schemeRemoteMap.putIfAbsent(scheme, ep.isRemote());
+        }
+
+        List<TopologyExternalEndpoint> externalEndpoints = new ArrayList<>();
+        Set<String> seenOutgoing = new HashSet<>();
+
+        for (RouteDefinition rd : routeDefs) {
+            String routeId = rd.getRouteId();
+
+            // Consumer (direction=in): each route has exactly 1 "from" 
endpoint
+            String inputUri = 
URISupport.stripQuery(rd.getInput().getEndpointUri());
+            String inputScheme = extractScheme(inputUri);
+            if (isRemoteScheme(inputScheme, schemeRemoteMap)) {
+                externalEndpoints.add(
+                        new TopologyExternalEndpoint("in-" + routeId, 
inputUri, inputScheme, "in", routeId));
+            }
+
+            // Producers (direction=out): 0..N output endpoints per route
+            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)) {
+                    // Deduplicate per route: same route sending to same URI 
only listed once
+                    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 String extractScheme(String uri) {
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);


Reply via email to