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);
