You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/06/01 13:53:22 UTC
(camel) branch CAMEL-23656/route-topology-service updated (7a222b7d8723 -> 4a8a391b941f)
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a change to branch CAMEL-23656/route-topology-service
in repository https://gitbox.apache.org/repos/asf/camel.git
from 7a222b7d8723 CAMEL-23656: Add route topology service to compute inter-route relationships
new 233e9720d0fb CAMEL-23661: camel-diagram - Add topology diagram renderer for inter-route visualization
new 5179c46ad173 CAMEL-23661: camel-diagram - Add --theme, --description, --metric to topology diagram
new 4a8a391b941f CAMEL-23661: camel-diagram - Use solid lines for all topology edges
The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
.../camel/diagram/DefaultRouteDiagramDumper.java | 53 +++
.../apache/camel/diagram/DiagramDevConsole.java | 57 ++-
.../camel/diagram/TopologyAsciiRenderer.java | 416 +++++++++++++++++++++
.../org/apache/camel/diagram/TopologyHelper.java | 99 +++++
.../camel/diagram/TopologyImageRenderer.java | 187 +++++++++
.../apache/camel/diagram/TopologyLayoutEngine.java | 346 +++++++++++++++++
.../apache/camel/diagram/TopologyDiagramTest.java | 352 +++++++++++++++++
.../apache/camel/diagram/TopologyDumperTest.java | 93 +++++
.../org/apache/camel/spi/RouteDiagramDumper.java | 26 ++
.../org/apache/camel/spi/RouteTopologyDumper.java | 13 +-
.../impl/console/RouteTopologyDevConsole.java | 21 ++
.../camel/impl/DefaultRouteTopologyDumper.java | 3 +-
.../camel-jbang-cmd-route-topology.adoc | 6 +
.../camel/cli/connector/LocalCliConnector.java | 7 +-
.../META-INF/camel-jbang-commands-metadata.json | 2 +-
.../commands/action/CamelRouteTopologyAction.java | 134 ++++++-
16 files changed, 1801 insertions(+), 14 deletions(-)
create mode 100644 components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java
create mode 100644 components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyHelper.java
create mode 100644 components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyImageRenderer.java
create mode 100644 components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyLayoutEngine.java
create mode 100644 components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyDiagramTest.java
create mode 100644 components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyDumperTest.java
(camel) 03/03: CAMEL-23661: camel-diagram - Use solid lines for all topology edges
Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch CAMEL-23656/route-topology-service
in repository https://gitbox.apache.org/repos/asf/camel.git
commit 4a8a391b941f95c56d07e2ce7b0a6be1d3003437
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Mon Jun 1 15:48:35 2026 +0200
CAMEL-23661: camel-diagram - Use solid lines for all topology edges
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
.../java/org/apache/camel/diagram/TopologyAsciiRenderer.java | 9 +++------
.../java/org/apache/camel/diagram/TopologyImageRenderer.java | 11 +----------
2 files changed, 4 insertions(+), 16 deletions(-)
diff --git a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java
index 8bba2d3b3780..61495af9a6ff 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
@@ -44,8 +44,7 @@ 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_H = '╌';
- private static final char UNI_DASH_V = '╎';
+
private final int nodeWidth;
private final int boxWidth;
@@ -222,10 +221,8 @@ public class TopologyAsciiRenderer {
return;
}
- boolean dashed = "external".equals(edge.connectionType);
-
- char v = dashed ? (unicode ? UNI_DASH_V : ':') : (unicode ? UNI_V : '|');
- char h = dashed ? (unicode ? UNI_DASH_H : '.') : (unicode ? UNI_H : '-');
+ char v = unicode ? UNI_V : '|';
+ char h = unicode ? UNI_H : '-';
char arrow = unicode ? UNI_ARROW : 'v';
if (fromCx == toCx) {
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 b8491af25c4a..977b5283cf63 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,16 +89,7 @@ public class TopologyImageRenderer {
continue;
}
- boolean dashed = "external".equals(edge.connectionType);
- java.awt.Stroke stroke;
- if (dashed) {
- stroke = new BasicStroke(
- strokeWidth, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER,
- 10.0f, new float[] { 10f, 5f }, 0.0f);
- } else {
- stroke = new BasicStroke(strokeWidth);
- }
- g.setStroke(stroke);
+ g.setStroke(new BasicStroke(strokeWidth));
g.setColor(colors.getArrow());
int fromCx = edge.from.x + nw / 2;
(camel) 01/03: CAMEL-23661: camel-diagram - Add topology diagram renderer for inter-route visualization
Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch CAMEL-23656/route-topology-service
in repository https://gitbox.apache.org/repos/asf/camel.git
commit 233e9720d0fb93212d4296cd59e6cecdff063f4b
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Mon Jun 1 15:08:26 2026 +0200
CAMEL-23661: camel-diagram - Add topology diagram renderer for inter-route visualization
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
.../camel/diagram/DefaultRouteDiagramDumper.java | 196 +++++++++
.../apache/camel/diagram/DiagramDevConsole.java | 57 ++-
.../camel/diagram/TopologyAsciiRenderer.java | 439 +++++++++++++++++++++
.../org/apache/camel/diagram/TopologyHelper.java | 96 +++++
.../apache/camel/diagram/TopologyLayoutEngine.java | 341 ++++++++++++++++
.../apache/camel/diagram/TopologyDiagramTest.java | 352 +++++++++++++++++
.../apache/camel/diagram/TopologyDumperTest.java | 93 +++++
.../org/apache/camel/spi/RouteDiagramDumper.java | 26 ++
.../camel-jbang-cmd-route-topology.adoc | 2 +
.../META-INF/camel-jbang-commands-metadata.json | 2 +-
.../commands/action/CamelRouteTopologyAction.java | 32 +-
11 files changed, 1632 insertions(+), 4 deletions(-)
diff --git a/components/camel-diagram/src/main/java/org/apache/camel/diagram/DefaultRouteDiagramDumper.java b/components/camel-diagram/src/main/java/org/apache/camel/diagram/DefaultRouteDiagramDumper.java
index 920588cb328e..7bc1964e3c09 100644
--- a/components/camel-diagram/src/main/java/org/apache/camel/diagram/DefaultRouteDiagramDumper.java
+++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/DefaultRouteDiagramDumper.java
@@ -194,4 +194,200 @@ public class DefaultRouteDiagramDumper extends ServiceSupport implements CamelCo
return renderAscii(routes, nodeWidth, nodeLabel, unicode, null, null);
}
+ @Override
+ public String dumpTopologyAsAsciiArt(int nodeWidth, boolean unicode) {
+ DevConsole dc = getCamelContext().getCamelContextExtension().getContextPlugin(DevConsoleRegistry.class)
+ .resolveById("route-topology");
+ if (dc == null) {
+ return "";
+ }
+ JsonObject root = (JsonObject) dc.call(DevConsole.MediaType.JSON);
+
+ var nodes = TopologyHelper.parseNodes(root);
+ var edges = TopologyHelper.parseEdges(root);
+
+ TopologyLayoutEngine engine = new TopologyLayoutEngine(nodeWidth);
+ TopologyLayoutEngine.TopologyLayoutResult result = engine.layout(nodes, edges);
+
+ TopologyAsciiRenderer renderer = new TopologyAsciiRenderer(
+ nodeWidth * RouteDiagramLayoutEngine.SCALE, unicode);
+ return renderer.renderDiagram(result);
+ }
+
+ @Override
+ public BufferedImage dumpTopologyAsImage(Theme theme, boolean metrics, int nodeWidth, int fontSize) {
+ DevConsole dc = getCamelContext().getCamelContextExtension().getContextPlugin(DevConsoleRegistry.class)
+ .resolveById("route-topology");
+ if (dc == null) {
+ return null;
+ }
+ JsonObject root = (JsonObject) dc.call(DevConsole.MediaType.JSON);
+
+ var nodes = TopologyHelper.parseNodes(root);
+ var edges = TopologyHelper.parseEdges(root);
+
+ // Enrich with metrics from route-structure console
+ if (metrics) {
+ DevConsole structureDc = getCamelContext().getCamelContextExtension()
+ .getContextPlugin(DevConsoleRegistry.class)
+ .resolveById("route-structure");
+ if (structureDc != null) {
+ JsonObject structureJson = (JsonObject) structureDc.call(
+ DevConsole.MediaType.JSON, Map.of("filter", "*", "metric", "true"));
+ TopologyHelper.enrichWithMetrics(nodes, structureJson);
+ }
+ }
+
+ TopologyLayoutEngine engine = new TopologyLayoutEngine(nodeWidth);
+ TopologyLayoutEngine.TopologyLayoutResult result = engine.layout(nodes, edges);
+
+ // Render as image using topology PNG renderer
+ return renderTopologyImage(result, theme != null ? theme.name() : "dark", fontSize, nodeWidth, metrics);
+ }
+
+ private static BufferedImage renderTopologyImage(
+ TopologyLayoutEngine.TopologyLayoutResult result, String theme,
+ int fontSize, int nodeWidth, boolean metrics) {
+ int scale = RouteDiagramLayoutEngine.SCALE;
+ int width = result.totalWidth + RouteDiagramLayoutEngine.PADDING;
+ int height = result.totalHeight + RouteDiagramLayoutEngine.PADDING;
+
+ width = Math.max(width, nodeWidth * scale + RouteDiagramLayoutEngine.PADDING * 2);
+ height = Math.max(height, 100 * scale);
+
+ RouteDiagramRenderer.DiagramColors colors = RouteDiagramRenderer.DiagramColors.parse(theme.toLowerCase());
+ java.awt.Color bgColor = colors.getBg();
+
+ int imageType = bgColor != null ? BufferedImage.TYPE_INT_RGB : BufferedImage.TYPE_INT_ARGB;
+ BufferedImage image = new BufferedImage(
+ Math.min(width, 16384), Math.min(height, 16384), imageType);
+
+ java.awt.Graphics2D g = image.createGraphics();
+ g.setRenderingHint(
+ java.awt.RenderingHints.KEY_ANTIALIASING, java.awt.RenderingHints.VALUE_ANTIALIAS_ON);
+ g.setRenderingHint(
+ java.awt.RenderingHints.KEY_TEXT_ANTIALIASING, java.awt.RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+
+ if (bgColor != null) {
+ g.setColor(bgColor);
+ g.fillRect(0, 0, image.getWidth(), image.getHeight());
+ }
+
+ int fontSizeScaled = fontSize * scale;
+ java.awt.Font font = new java.awt.Font(java.awt.Font.SANS_SERIF, java.awt.Font.PLAIN, fontSizeScaled);
+ g.setFont(font);
+ java.awt.FontMetrics fm = g.getFontMetrics();
+
+ int arc = 14 * scale;
+ float strokeWidth = 1.5f * scale;
+ int arrowSize = 6 * scale;
+ int nw = nodeWidth * scale;
+
+ // Draw edges
+ for (TopologyLayoutEngine.TopologyLayoutEdge edge : result.edges) {
+ if (edge.selfLoop) {
+ continue;
+ }
+
+ boolean dashed = "external".equals(edge.connectionType);
+ java.awt.Stroke stroke;
+ if (dashed) {
+ stroke = new java.awt.BasicStroke(
+ strokeWidth, java.awt.BasicStroke.CAP_BUTT, java.awt.BasicStroke.JOIN_MITER,
+ 10.0f, new float[] { 10f, 5f }, 0.0f);
+ } else {
+ stroke = new java.awt.BasicStroke(strokeWidth);
+ }
+ g.setStroke(stroke);
+ g.setColor(colors.getArrow());
+
+ int fromCx = edge.from.x + nw / 2;
+ int fromBy = edge.from.y + edge.from.height;
+ int toCx = edge.to.x + nw / 2;
+ int toTy = edge.to.y;
+
+ if (fromCx == toCx) {
+ g.drawLine(fromCx, fromBy, toCx, toTy - arrowSize / 2);
+ } else {
+ int midY = fromBy + (toTy - fromBy) / 2;
+ g.drawLine(fromCx, fromBy, fromCx, midY);
+ g.drawLine(fromCx, midY, toCx, midY);
+ g.drawLine(toCx, midY, toCx, toTy - arrowSize / 2);
+ }
+
+ // Arrow head
+ int[] xPoints = { toCx - arrowSize, toCx + arrowSize, toCx };
+ int[] yPoints = { toTy - arrowSize, toTy - arrowSize, toTy };
+ g.fillPolygon(xPoints, yPoints, 3);
+
+ // Edge label
+ if (edge.endpoint != null) {
+ g.setColor(colors.getText());
+ java.awt.Font labelFont = font.deriveFont((float) (fontSizeScaled * 0.85));
+ g.setFont(labelFont);
+ int labelX = Math.min(fromCx, toCx) + Math.abs(fromCx - toCx) / 2;
+ int labelY = fromBy + (toTy - fromBy) / 2 - fontSizeScaled / 2;
+ String label = edge.endpoint;
+ int labelWidth = g.getFontMetrics().stringWidth(label);
+ g.drawString(label, labelX - labelWidth / 2, labelY);
+ g.setFont(font);
+ }
+ }
+
+ // Draw nodes
+ for (TopologyLayoutEngine.TopologyLayoutNode node : result.nodes) {
+ java.awt.Color nodeColor;
+ if ("trigger".equals(node.nodeType)) {
+ nodeColor = colors.getNodeFrom();
+ } else {
+ nodeColor = colors.getNodeDefault();
+ }
+
+ // Node box
+ g.setColor(nodeColor);
+ g.setStroke(new java.awt.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);
+
+ // Text
+ g.setColor(colors.getText());
+ String line1 = node.routeId;
+ String line2 = "(" + node.from + ")";
+ int lineHeight = fm.getHeight();
+ int textY = node.y + (node.height - 2 * lineHeight) / 2 + fm.getAscent();
+
+ int line1Width = fm.stringWidth(line1);
+ g.drawString(line1, node.x + (nw - line1Width) / 2, textY);
+
+ java.awt.Font smallFont = font.deriveFont((float) (fontSizeScaled * 0.85));
+ g.setFont(smallFont);
+ int line2Width = g.getFontMetrics().stringWidth(line2);
+ g.drawString(line2, node.x + (nw - line2Width) / 2, textY + lineHeight);
+ g.setFont(font);
+
+ // Metrics
+ if (metrics && node.exchangesTotal > 0) {
+ long ok = node.exchangesTotal - node.exchangesFailed;
+ java.awt.Font metricsFont = font.deriveFont((float) (fontSizeScaled * 0.75));
+ g.setFont(metricsFont);
+ if (ok > 0) {
+ g.setColor(new java.awt.Color(0x43a047));
+ String okStr = String.valueOf(ok);
+ g.drawString(okStr, node.x + nw / 2 - g.getFontMetrics().stringWidth(okStr) - 4, textY + 2 * lineHeight);
+ }
+ if (node.exchangesFailed > 0) {
+ g.setColor(new java.awt.Color(0xe53935));
+ String failStr = String.valueOf(node.exchangesFailed);
+ g.drawString(failStr, node.x + nw / 2 + 4, textY + 2 * lineHeight);
+ }
+ g.setFont(font);
+ }
+ }
+
+ g.dispose();
+ return image;
+ }
+
}
diff --git a/components/camel-diagram/src/main/java/org/apache/camel/diagram/DiagramDevConsole.java b/components/camel-diagram/src/main/java/org/apache/camel/diagram/DiagramDevConsole.java
index e41c3b8cf15f..78a3881ab81e 100644
--- a/components/camel-diagram/src/main/java/org/apache/camel/diagram/DiagramDevConsole.java
+++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/DiagramDevConsole.java
@@ -64,6 +64,11 @@ public class DiagramDevConsole extends AbstractDevConsole {
*/
public static final String AUTO_REFRESH = "autoRefresh";
+ /**
+ * Diagram mode: route (default) or topology
+ */
+ public static final String MODE = "mode";
+
public DiagramDevConsole() {
super("camel", "route-diagram", "Route Diagram", "Visual route diagrams");
}
@@ -72,6 +77,7 @@ public class DiagramDevConsole extends AbstractDevConsole {
protected String doCallText(Map<String, Object> options) {
final StringJoiner sj = new StringJoiner("\n");
+ String mode = (String) options.getOrDefault(MODE, "route");
String filter = (String) options.getOrDefault(FILTER, "*");
String theme = (String) options.getOrDefault(THEME, RouteDiagramDumper.Theme.TRANSPARENT.name());
int fontSize
@@ -84,7 +90,9 @@ public class DiagramDevConsole extends AbstractDevConsole {
try {
RouteDiagramDumper dumper = PluginHelper.getRouteDiagramDumper(getCamelContext());
- if (isTextTheme(theme)) {
+ if ("topology".equalsIgnoreCase(mode)) {
+ sj.add(doCallTopologyText(dumper, theme, nodeWidth, metric, fontSize, refresh));
+ } else if (isTextTheme(theme)) {
String text = dumper.dumpRoutesAsAsciiArt(filter,
RouteDiagramDumper.NodeLabelMode.valueOf(nodeLabel.toUpperCase()),
nodeWidth, isUnicodeTheme(theme));
@@ -112,6 +120,7 @@ public class DiagramDevConsole extends AbstractDevConsole {
@Override
protected Map<String, Object> doCallJson(Map<String, Object> options) {
+ String mode = (String) options.getOrDefault(MODE, "route");
String filter = (String) options.getOrDefault(FILTER, "*");
String theme = (String) options.getOrDefault(THEME, RouteDiagramDumper.Theme.TRANSPARENT.name());
int fontSize
@@ -124,7 +133,9 @@ public class DiagramDevConsole extends AbstractDevConsole {
JsonObject root = new JsonObject();
try {
RouteDiagramDumper dumper = PluginHelper.getRouteDiagramDumper(getCamelContext());
- if (isTextTheme(theme)) {
+ if ("topology".equalsIgnoreCase(mode)) {
+ return doCallTopologyJson(dumper, theme, nodeWidth, metric, fontSize);
+ } else if (isTextTheme(theme)) {
String text = dumper.dumpRoutesAsAsciiArt(filter,
RouteDiagramDumper.NodeLabelMode.valueOf(nodeLabel.toUpperCase()),
nodeWidth, isUnicodeTheme(theme));
@@ -143,6 +154,48 @@ public class DiagramDevConsole extends AbstractDevConsole {
return root;
}
+ private String doCallTopologyText(
+ RouteDiagramDumper dumper, String theme, int nodeWidth,
+ boolean metric, int fontSize, boolean refresh)
+ throws Exception {
+ if (isTextTheme(theme)) {
+ return dumper.dumpTopologyAsAsciiArt(nodeWidth, isUnicodeTheme(theme));
+ } else {
+ BufferedImage image = dumper.dumpTopologyAsImage(
+ RouteDiagramDumper.Theme.valueOf(theme.toUpperCase()), metric, nodeWidth, fontSize);
+ if (image == null) {
+ return "";
+ }
+ String base64 = dumper.imageToBase64(image);
+ String html = String.format(
+ " <body>\n <img src=\"data:image/png;base64,%s\" alt=\"Route Topology\">\n </body>\n",
+ base64);
+ if (refresh) {
+ html = "<head><meta http-equiv=\"refresh\" content=\"5\"></head>\n" + html;
+ }
+ return "<html>\n" + html + "</html>\n";
+ }
+ }
+
+ private Map<String, Object> doCallTopologyJson(
+ RouteDiagramDumper dumper, String theme, int nodeWidth,
+ boolean metric, int fontSize)
+ throws Exception {
+ JsonObject root = new JsonObject();
+ if (isTextTheme(theme)) {
+ String text = dumper.dumpTopologyAsAsciiArt(nodeWidth, isUnicodeTheme(theme));
+ root.put("text", text);
+ } else {
+ BufferedImage image = dumper.dumpTopologyAsImage(
+ RouteDiagramDumper.Theme.valueOf(theme.toUpperCase()), metric, nodeWidth, fontSize);
+ if (image != null) {
+ String base64 = dumper.imageToBase64(image);
+ root.put("image", base64);
+ }
+ }
+ return root;
+ }
+
private static boolean isTextTheme(String theme) {
return "ascii".equalsIgnoreCase(theme) || "unicode".equalsIgnoreCase(theme);
}
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
new file mode 100644
index 000000000000..b324edb095ec
--- /dev/null
+++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java
@@ -0,0 +1,439 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.diagram;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutEdge;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutNode;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutResult;
+
+/**
+ * Renders topology diagrams as ASCII art or Unicode box-drawing text.
+ */
+public class TopologyAsciiRenderer {
+
+ private static final int Y_SCALE = 20;
+ private static final int MIN_BOX_WIDTH = 16;
+ private static final int X_DIVISOR = 15;
+ private static final int MAX_WRAP_LINES = 3;
+
+ private static final char UNI_H = '─';
+ private static final char UNI_V = '│';
+ private static final char UNI_TL = '┌';
+ private static final char UNI_TR = '┐';
+ private static final char UNI_BL = '└';
+ private static final char UNI_BR = '┘';
+ private static final char UNI_T_DOWN = '┬';
+ 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_H = '╌';
+ private static final char UNI_DASH_V = '╎';
+
+ private final int nodeWidth;
+ private final int boxWidth;
+ private final boolean unicode;
+ private final boolean metrics;
+ private final List<CounterPos> counterPositions = new ArrayList<>();
+
+ enum CounterType {
+ OK,
+ FAIL,
+ TRIGGER,
+ EXTERNAL
+ }
+
+ record CounterPos(int row, int col, int length, CounterType type) {
+ }
+
+ public TopologyAsciiRenderer(int nodeWidth, boolean unicode) {
+ this(nodeWidth, unicode, false);
+ }
+
+ public TopologyAsciiRenderer(int nodeWidth, boolean unicode, boolean metrics) {
+ this.nodeWidth = nodeWidth;
+ this.boxWidth = Math.max(MIN_BOX_WIDTH, nodeWidth / X_DIVISOR);
+ this.unicode = unicode;
+ this.metrics = metrics;
+ }
+
+ public int getBoxWidth() {
+ return boxWidth;
+ }
+
+ public String renderDiagram(TopologyLayoutResult result) {
+ counterPositions.clear();
+
+ int gridWidth = toCol(result.totalWidth) + boxWidth + 4;
+ int gridHeight = toRow(result.totalHeight) + 10;
+ gridWidth = Math.max(gridWidth, 40);
+ gridHeight = Math.max(gridHeight, 10);
+
+ char[][] grid = new char[gridHeight][gridWidth];
+ for (char[] row : grid) {
+ Arrays.fill(row, ' ');
+ }
+
+ // Draw edges first (behind nodes)
+ for (TopologyLayoutEdge edge : result.edges) {
+ if (!edge.selfLoop) {
+ drawEdge(grid, edge);
+ }
+ }
+
+ // Draw self-loops
+ for (TopologyLayoutEdge edge : result.edges) {
+ if (edge.selfLoop) {
+ drawSelfLoop(grid, edge);
+ }
+ }
+
+ // Draw nodes on top
+ for (TopologyLayoutNode node : result.nodes) {
+ drawNode(grid, node);
+ }
+
+ String plain = gridToString(grid);
+ return applyAnsiColors(plain);
+ }
+
+ private void drawNode(char[][] grid, TopologyLayoutNode node) {
+ int col = toCol(node.x);
+ int row = toRow(node.y);
+
+ String line1 = node.routeId;
+ String line2 = "(" + node.from + ")";
+
+ List<String> lines = new ArrayList<>();
+ lines.addAll(wrapText(line1, boxWidth - 4));
+ lines.addAll(wrapText(line2, boxWidth - 4));
+
+ if (metrics && (node.exchangesTotal > 0 || node.exchangesFailed > 0)) {
+ long ok = node.exchangesTotal - node.exchangesFailed;
+ StringBuilder sb = new StringBuilder();
+ if (ok > 0) {
+ sb.append(ok);
+ }
+ if (node.exchangesFailed > 0) {
+ if (!sb.isEmpty()) {
+ sb.append("/");
+ }
+ sb.append(node.exchangesFailed).append("!");
+ }
+ lines.add(sb.toString());
+ }
+
+ // Trim to MAX_WRAP_LINES
+ while (lines.size() > MAX_WRAP_LINES + 1) {
+ lines.remove(lines.size() - 1);
+ }
+
+ int height = 2 + lines.size();
+ if (row + height >= grid.length) {
+ return;
+ }
+
+ char h = unicode ? UNI_H : '-';
+ char v = unicode ? UNI_V : '|';
+
+ // Top border
+ setChar(grid, row, col, unicode ? UNI_TL : '+');
+ for (int c = col + 1; c < col + boxWidth - 1; c++) {
+ setChar(grid, row, c, h);
+ }
+ setChar(grid, row, col + boxWidth - 1, unicode ? UNI_TR : '+');
+
+ // Bottom border
+ int bottom = row + height - 1;
+ setChar(grid, bottom, col, unicode ? UNI_BL : '+');
+ for (int c = col + 1; c < col + boxWidth - 1; c++) {
+ setChar(grid, bottom, c, h);
+ }
+ setChar(grid, bottom, col + boxWidth - 1, unicode ? UNI_BR : '+');
+
+ // Content
+ int innerWidth = boxWidth - 4;
+ for (int i = 0; i < lines.size(); i++) {
+ int r = row + 1 + i;
+ setChar(grid, r, col, v);
+ setChar(grid, r, col + boxWidth - 1, v);
+ for (int c = col + 1; c < col + boxWidth - 1; c++) {
+ setChar(grid, r, c, ' ');
+ }
+ String text = lines.get(i);
+ if (text.length() > innerWidth) {
+ text = text.substring(0, Math.max(1, innerWidth - 3)) + "...";
+ }
+ int textCol = col + 2 + Math.max(0, (innerWidth - text.length()) / 2);
+ drawText(grid, r, textCol, text);
+
+ // Record ANSI color for trigger nodes and metrics
+ if (i == 0 && "trigger".equals(node.nodeType)) {
+ counterPositions.add(new CounterPos(r, textCol, text.length(), CounterType.TRIGGER));
+ }
+ if (metrics && i == lines.size() - 1 && node.exchangesTotal > 0) {
+ long ok = node.exchangesTotal - node.exchangesFailed;
+ if (ok > 0) {
+ String okStr = "" + ok;
+ counterPositions.add(new CounterPos(r, textCol, okStr.length(), CounterType.OK));
+ }
+ if (node.exchangesFailed > 0) {
+ String failStr = node.exchangesFailed + "!";
+ int failCol = textCol + text.length() - failStr.length();
+ counterPositions.add(new CounterPos(r, failCol, failStr.length(), CounterType.FAIL));
+ }
+ }
+ }
+ }
+
+ private void drawEdge(char[][] grid, TopologyLayoutEdge edge) {
+ int fromCx = toCol(edge.from.x + edge.from.width / 2);
+ int fromBottom = toRow(edge.from.y) + boxHeight(edge.from);
+ int toCx = toCol(edge.to.x + edge.to.width / 2);
+ int toTop = toRow(edge.to.y);
+
+ if (fromBottom >= toTop) {
+ return;
+ }
+
+ boolean dashed = "external".equals(edge.connectionType);
+
+ 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) {
+ for (int r = fromBottom; r < toTop - 1; r++) {
+ plotLine(grid, r, fromCx, v);
+ }
+ setChar(grid, toTop - 1, toCx, arrow);
+ } else {
+ int midRow = fromBottom + (toTop - fromBottom) / 2;
+
+ for (int r = fromBottom; r < midRow; r++) {
+ plotLine(grid, r, fromCx, v);
+ }
+
+ int minC = Math.min(fromCx, toCx);
+ int maxC = Math.max(fromCx, toCx);
+ for (int c = minC; c <= maxC; c++) {
+ plotLine(grid, midRow, c, h);
+ }
+
+ if (unicode) {
+ setChar(grid, midRow, fromCx, UNI_T_UP);
+ setChar(grid, midRow, toCx, UNI_T_DOWN);
+ } else {
+ setChar(grid, midRow, fromCx, '+');
+ setChar(grid, midRow, toCx, '+');
+ }
+
+ for (int r = midRow + 1; r < toTop - 1; r++) {
+ plotLine(grid, r, toCx, v);
+ }
+ setChar(grid, toTop - 1, toCx, arrow);
+ }
+
+ // Draw edge label at midpoint
+ String label = edge.endpoint;
+ if (label != null && !label.isEmpty()) {
+ int labelRow;
+ int labelCol;
+ if (fromCx == toCx) {
+ labelRow = fromBottom + (toTop - fromBottom) / 2;
+ labelCol = fromCx + 2;
+ } else {
+ int midRow = fromBottom + (toTop - fromBottom) / 2;
+ labelRow = midRow - 1;
+ labelCol = Math.min(fromCx, toCx) + 1;
+ }
+ if (label.length() > boxWidth) {
+ label = label.substring(0, boxWidth - 3) + "...";
+ }
+ drawText(grid, labelRow, labelCol, label);
+
+ if (dashed) {
+ counterPositions.add(new CounterPos(labelRow, labelCol, label.length(), CounterType.EXTERNAL));
+ }
+ }
+ }
+
+ private void drawSelfLoop(char[][] grid, TopologyLayoutEdge edge) {
+ int col = toCol(edge.from.x) + boxWidth;
+ int topRow = toRow(edge.from.y) + 1;
+ int botRow = topRow + 2;
+
+ char h = unicode ? UNI_H : '-';
+ char v = unicode ? UNI_V : '|';
+
+ // Small loop on right side
+ for (int c = col; c < col + 3; c++) {
+ setChar(grid, topRow, c, h);
+ setChar(grid, botRow, c, h);
+ }
+ setChar(grid, topRow + 1, col + 2, v);
+ setChar(grid, topRow, col + 2, unicode ? UNI_TR : '+');
+ setChar(grid, botRow, col + 2, unicode ? UNI_BR : '+');
+
+ String label = edge.endpoint;
+ if (label != null && !label.isEmpty()) {
+ if (label.length() > 10) {
+ label = label.substring(0, 7) + "...";
+ }
+ drawText(grid, topRow + 1, col + 4, label);
+ }
+ }
+
+ private int boxHeight(TopologyLayoutNode node) {
+ int lines = 2; // routeId + from
+ if (metrics && node.exchangesTotal > 0) {
+ lines++;
+ }
+ return 2 + Math.min(lines, MAX_WRAP_LINES + 1);
+ }
+
+ static List<String> wrapText(String text, int maxWidth) {
+ if (maxWidth <= 0 || text.length() <= maxWidth) {
+ return new ArrayList<>(List.of(text));
+ }
+
+ List<String> lines = new ArrayList<>();
+ String remaining = text;
+
+ while (!remaining.isEmpty() && lines.size() < MAX_WRAP_LINES) {
+ if (remaining.length() <= maxWidth) {
+ lines.add(remaining);
+ remaining = "";
+ break;
+ }
+
+ int breakAt = -1;
+ for (int i = 0; i < maxWidth && i < remaining.length(); i++) {
+ char c = remaining.charAt(i);
+ if (c == ' ' || c == ':' || c == '/' || c == '.' || c == ',' || c == '&' || c == '?') {
+ breakAt = i + 1;
+ }
+ }
+ if (breakAt <= 0) {
+ breakAt = maxWidth;
+ }
+
+ lines.add(remaining.substring(0, breakAt).stripTrailing());
+ remaining = remaining.substring(breakAt).stripLeading();
+ }
+
+ if (!remaining.isEmpty()) {
+ int lastIdx = lines.size() - 1;
+ String lastLine = lines.get(lastIdx);
+ String combined = lastLine + remaining;
+ lines.set(lastIdx, combined.substring(0, Math.max(1, maxWidth - 3)) + "...");
+ }
+
+ return lines;
+ }
+
+ private String applyAnsiColors(String plain) {
+ if (counterPositions.isEmpty()) {
+ return plain;
+ }
+ String[] lines = plain.split("\n", -1);
+ for (CounterPos cp : counterPositions) {
+ if (cp.row >= 0 && cp.row < lines.length) {
+ String line = lines[cp.row];
+ if (cp.col >= 0 && cp.col + cp.length <= line.length()) {
+ String before = line.substring(0, cp.col);
+ String counter = line.substring(cp.col, cp.col + cp.length);
+ String after = line.substring(cp.col + cp.length);
+ String color = switch (cp.type) {
+ case OK -> "\033[32m";
+ case FAIL -> "\033[31m";
+ case TRIGGER -> "\033[33m";
+ case EXTERNAL -> "\033[36m";
+ };
+ lines[cp.row] = before + color + counter + "\033[0m" + after;
+ }
+ }
+ }
+ return String.join("\n", lines);
+ }
+
+ private int toCol(int pixelX) {
+ if (nodeWidth == 0) {
+ return 0;
+ }
+ return pixelX * boxWidth / nodeWidth;
+ }
+
+ private int toRow(int pixelY) {
+ return pixelY / Y_SCALE;
+ }
+
+ private void setChar(char[][] grid, int row, int col, char ch) {
+ if (row >= 0 && row < grid.length && col >= 0 && col < grid[0].length) {
+ grid[row][col] = ch;
+ }
+ }
+
+ private char getChar(char[][] grid, int row, int col) {
+ if (row >= 0 && row < grid.length && col >= 0 && col < grid[0].length) {
+ return grid[row][col];
+ }
+ return ' ';
+ }
+
+ private boolean isVertical(char ch) {
+ return ch == '|' || ch == UNI_V;
+ }
+
+ private boolean isHorizontal(char ch) {
+ return ch == '-' || ch == UNI_H;
+ }
+
+ private void plotLine(char[][] grid, int row, int col, char ch) {
+ char current = getChar(grid, row, col);
+ if ((isVertical(current) && isHorizontal(ch)) || (isHorizontal(current) && isVertical(ch))) {
+ setChar(grid, row, col, unicode ? UNI_CROSS : '+');
+ } else {
+ setChar(grid, row, col, ch);
+ }
+ }
+
+ private void drawText(char[][] grid, int row, int col, String text) {
+ for (int i = 0; i < text.length(); i++) {
+ setChar(grid, row, col + i, text.charAt(i));
+ }
+ }
+
+ private String gridToString(char[][] grid) {
+ int lastRow = 0;
+ for (int r = 0; r < grid.length; r++) {
+ if (!new String(grid[r]).isBlank()) {
+ lastRow = r;
+ }
+ }
+ StringBuilder sb = new StringBuilder();
+ for (int r = 0; r <= lastRow; r++) {
+ sb.append(new String(grid[r]).stripTrailing());
+ sb.append('\n');
+ }
+ return sb.toString();
+ }
+
+}
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
new file mode 100644
index 000000000000..49e2722e8c6f
--- /dev/null
+++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyHelper.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.diagram;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyEdgeInfo;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyNodeInfo;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * Helper for parsing topology JSON from the route-topology dev console and enriching with per-route metrics from the
+ * route-structure dev console.
+ */
+public final class TopologyHelper {
+
+ private TopologyHelper() {
+ }
+
+ public static List<TopologyNodeInfo> parseNodes(JsonObject jo) {
+ List<TopologyNodeInfo> nodes = new ArrayList<>();
+ JsonArray arr = jo.getJsonArray("nodes");
+ if (arr == null) {
+ return nodes;
+ }
+ for (int i = 0; i < arr.size(); i++) {
+ JsonObject no = arr.getJsonObject(i);
+ TopologyNodeInfo node = new TopologyNodeInfo();
+ node.routeId = no.getString("routeId");
+ node.from = no.getString("from");
+ node.fromScheme = no.getString("fromScheme");
+ node.nodeType = no.getStringOrDefault("nodeType", "route");
+ nodes.add(node);
+ }
+ return nodes;
+ }
+
+ public static List<TopologyEdgeInfo> parseEdges(JsonObject jo) {
+ List<TopologyEdgeInfo> edges = new ArrayList<>();
+ JsonArray arr = jo.getJsonArray("edges");
+ if (arr == null) {
+ return edges;
+ }
+ for (int i = 0; i < arr.size(); i++) {
+ JsonObject eo = arr.getJsonObject(i);
+ TopologyEdgeInfo edge = new TopologyEdgeInfo();
+ edge.fromRouteId = eo.getString("fromRouteId");
+ edge.toRouteId = eo.getString("toRouteId");
+ edge.endpoint = eo.getString("endpoint");
+ edge.connectionType = eo.getStringOrDefault("connectionType", "internal");
+ edges.add(edge);
+ }
+ return edges;
+ }
+
+ public static void enrichWithMetrics(List<TopologyNodeInfo> nodes, JsonObject routeStructureJson) {
+ if (routeStructureJson == null) {
+ return;
+ }
+ JsonArray routes = routeStructureJson.getJsonArray("routes");
+ if (routes == null) {
+ return;
+ }
+ for (int i = 0; i < routes.size(); i++) {
+ JsonObject ro = routes.getJsonObject(i);
+ String routeId = ro.getString("routeId");
+ JsonObject stats = ro.getJsonObject("statistics");
+ if (routeId != null && stats != null) {
+ for (TopologyNodeInfo node : nodes) {
+ if (routeId.equals(node.routeId)) {
+ node.exchangesTotal = stats.getLongOrDefault("exchangesTotal", 0);
+ node.exchangesFailed = stats.getLongOrDefault("exchangesFailed", 0);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+}
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
new file mode 100644
index 000000000000..49ed81ef506a
--- /dev/null
+++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyLayoutEngine.java
@@ -0,0 +1,341 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.diagram;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+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.
+ */
+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 PADDING = RouteDiagramLayoutEngine.PADDING;
+ static final int DEFAULT_NODE_WIDTH = 180;
+ static final int DEFAULT_NODE_HEIGHT = 40;
+ static final int DEFAULT_FONT_SIZE = 12;
+
+ private final int nodeWidth;
+ private final int nodeHeight;
+
+ public TopologyLayoutEngine() {
+ this(DEFAULT_NODE_WIDTH);
+ }
+
+ public TopologyLayoutEngine(int nodeWidth) {
+ this.nodeWidth = nodeWidth * SCALE;
+ this.nodeHeight = DEFAULT_NODE_HEIGHT * SCALE;
+ }
+
+ public int getNodeWidth() {
+ return nodeWidth;
+ }
+
+ public int getNodeHeight() {
+ return nodeHeight;
+ }
+
+ public TopologyLayoutResult layout(List<TopologyNodeInfo> nodes, List<TopologyEdgeInfo> edges) {
+ if (nodes.isEmpty()) {
+ return new TopologyLayoutResult(Collections.emptyList(), Collections.emptyList(), 0, 0);
+ }
+
+ Map<String, TopologyNodeInfo> nodeMap = new HashMap<>();
+ for (TopologyNodeInfo n : nodes) {
+ nodeMap.put(n.routeId, n);
+ }
+
+ Map<String, List<String>> successors = new HashMap<>();
+ Map<String, List<String>> predecessors = new HashMap<>();
+ for (TopologyNodeInfo n : nodes) {
+ successors.put(n.routeId, new ArrayList<>());
+ predecessors.put(n.routeId, new ArrayList<>());
+ }
+ for (TopologyEdgeInfo e : edges) {
+ if (nodeMap.containsKey(e.fromRouteId) && nodeMap.containsKey(e.toRouteId)) {
+ successors.get(e.fromRouteId).add(e.toRouteId);
+ predecessors.get(e.toRouteId).add(e.fromRouteId);
+ }
+ }
+
+ // Layer assignment
+ Map<String, Integer> layers = assignLayers(nodes, successors, predecessors);
+
+ // Group nodes by layer
+ int maxLayer = layers.values().stream().mapToInt(Integer::intValue).max().orElse(0);
+ List<List<String>> layerGroups = new ArrayList<>();
+ for (int i = 0; i <= maxLayer; i++) {
+ layerGroups.add(new ArrayList<>());
+ }
+ for (TopologyNodeInfo n : nodes) {
+ int layer = layers.getOrDefault(n.routeId, 0);
+ layerGroups.get(layer).add(n.routeId);
+ }
+
+ // Minimize crossings (barycenter heuristic)
+ minimizeCrossings(layerGroups, successors, predecessors);
+
+ // Assign coordinates
+ Map<String, TopologyLayoutNode> layoutNodes = assignCoordinates(layerGroups, nodeMap);
+
+ // Build layout edges
+ List<TopologyLayoutEdge> layoutEdges = new ArrayList<>();
+ for (TopologyEdgeInfo e : edges) {
+ TopologyLayoutNode from = layoutNodes.get(e.fromRouteId);
+ TopologyLayoutNode to = layoutNodes.get(e.toRouteId);
+ if (from != null && to != null) {
+ boolean backEdge = layers.getOrDefault(e.fromRouteId, 0) >= layers.getOrDefault(e.toRouteId, 0)
+ && !e.fromRouteId.equals(e.toRouteId);
+ boolean selfLoop = e.fromRouteId.equals(e.toRouteId);
+ layoutEdges.add(new TopologyLayoutEdge(from, to, e.endpoint, e.connectionType, backEdge, selfLoop));
+ }
+ }
+
+ int totalWidth = layoutNodes.values().stream().mapToInt(n -> n.x + nodeWidth).max().orElse(0) + PADDING;
+ int totalHeight = layoutNodes.values().stream().mapToInt(n -> n.y + n.height).max().orElse(0) + PADDING;
+
+ return new TopologyLayoutResult(
+ new ArrayList<>(layoutNodes.values()), layoutEdges, totalWidth, totalHeight);
+ }
+
+ private Map<String, Integer> assignLayers(
+ List<TopologyNodeInfo> nodes,
+ Map<String, List<String>> successors,
+ Map<String, List<String>> predecessors) {
+
+ Map<String, Integer> layers = new HashMap<>();
+
+ // Triggers and nodes with no predecessors go to layer 0
+ Set<String> assigned = new HashSet<>();
+ for (TopologyNodeInfo n : nodes) {
+ if ("trigger".equals(n.nodeType) || predecessors.get(n.routeId).isEmpty()) {
+ 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);
+ }
+
+ // BFS-style layer assignment
+ boolean changed = true;
+ while (changed) {
+ changed = false;
+ for (TopologyNodeInfo n : nodes) {
+ if (assigned.contains(n.routeId)) {
+ for (String succ : successors.get(n.routeId)) {
+ if (succ.equals(n.routeId)) {
+ continue; // skip self-loops
+ }
+ int newLayer = layers.get(n.routeId) + 1;
+ if (!assigned.contains(succ) || layers.get(succ) < newLayer) {
+ layers.put(succ, newLayer);
+ assigned.add(succ);
+ changed = true;
+ }
+ }
+ }
+ }
+ }
+
+ // Handle any unassigned nodes (isolated or in pure cycles)
+ for (TopologyNodeInfo n : nodes) {
+ layers.putIfAbsent(n.routeId, 0);
+ }
+
+ return layers;
+ }
+
+ private void minimizeCrossings(
+ List<List<String>> layerGroups,
+ Map<String, List<String>> successors,
+ Map<String, List<String>> predecessors) {
+
+ // Barycenter heuristic: order nodes by average position of neighbors in adjacent layer
+ for (int pass = 0; pass < 4; pass++) {
+ // Forward pass
+ for (int i = 1; i < layerGroups.size(); i++) {
+ orderByBarycenter(layerGroups.get(i), layerGroups.get(i - 1), predecessors);
+ }
+ // Backward pass
+ for (int i = layerGroups.size() - 2; i >= 0; i--) {
+ orderByBarycenter(layerGroups.get(i), layerGroups.get(i + 1), successors);
+ }
+ }
+ }
+
+ private void orderByBarycenter(
+ List<String> layer, List<String> referenceLayer,
+ Map<String, List<String>> neighbors) {
+
+ Map<String, Integer> refPositions = new HashMap<>();
+ for (int i = 0; i < referenceLayer.size(); i++) {
+ refPositions.put(referenceLayer.get(i), i);
+ }
+
+ Map<String, Double> barycenters = new HashMap<>();
+ for (String nodeId : layer) {
+ List<String> connected = neighbors.get(nodeId);
+ if (connected == null || connected.isEmpty()) {
+ barycenters.put(nodeId, Double.MAX_VALUE);
+ continue;
+ }
+ double sum = 0;
+ int count = 0;
+ for (String neighbor : connected) {
+ Integer pos = refPositions.get(neighbor);
+ if (pos != null) {
+ sum += pos;
+ count++;
+ }
+ }
+ barycenters.put(nodeId, count > 0 ? sum / count : Double.MAX_VALUE);
+ }
+
+ layer.sort((a, b) -> Double.compare(barycenters.get(a), barycenters.get(b)));
+ }
+
+ private Map<String, TopologyLayoutNode> assignCoordinates(
+ List<List<String>> layerGroups,
+ Map<String, TopologyNodeInfo> nodeMap) {
+
+ Map<String, TopologyLayoutNode> layoutNodes = new HashMap<>();
+
+ // Find widest layer for centering
+ int maxLayerWidth = 0;
+ for (List<String> layer : layerGroups) {
+ int width = layer.size() * (nodeWidth + H_GAP) - H_GAP;
+ maxLayerWidth = Math.max(maxLayerWidth, width);
+ }
+
+ 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);
+ TopologyNodeInfo info = nodeMap.get(routeId);
+ int x = startX + i * (nodeWidth + H_GAP);
+ TopologyLayoutNode ln = new TopologyLayoutNode(
+ routeId, info.from, info.nodeType, info.connectionType,
+ x, y, nodeWidth, nodeHeight, layerIdx);
+ layoutNodes.put(routeId, ln);
+ }
+ }
+
+ return layoutNodes;
+ }
+
+ // Input data structures
+ public static class TopologyNodeInfo {
+ public String routeId;
+ public String from;
+ public String fromScheme;
+ public String nodeType;
+ public String connectionType;
+ public long exchangesTotal;
+ public long exchangesFailed;
+ }
+
+ public static class TopologyEdgeInfo {
+ public String fromRouteId;
+ public String toRouteId;
+ public String endpoint;
+ public String connectionType;
+ }
+
+ // Layout output
+ public static class TopologyLayoutNode {
+ public final String routeId;
+ public final String from;
+ public final String nodeType;
+ public final String connectionType;
+ public int x;
+ public int y;
+ public int width;
+ public int height;
+ public final int layer;
+ public long exchangesTotal;
+ public long exchangesFailed;
+
+ public TopologyLayoutNode(
+ String routeId, String from, String nodeType, String connectionType,
+ int x, int y, int width, int height, int layer) {
+ this.routeId = routeId;
+ this.from = from;
+ this.nodeType = nodeType;
+ this.connectionType = connectionType;
+ this.x = x;
+ this.y = y;
+ this.width = width;
+ this.height = height;
+ this.layer = layer;
+ }
+ }
+
+ public static class TopologyLayoutEdge {
+ public final TopologyLayoutNode from;
+ public final TopologyLayoutNode to;
+ public final String endpoint;
+ public final String connectionType;
+ public final boolean backEdge;
+ public final boolean selfLoop;
+
+ public TopologyLayoutEdge(
+ TopologyLayoutNode from, TopologyLayoutNode to,
+ String endpoint, String connectionType,
+ boolean backEdge, boolean selfLoop) {
+ this.from = from;
+ this.to = to;
+ this.endpoint = endpoint;
+ this.connectionType = connectionType;
+ this.backEdge = backEdge;
+ this.selfLoop = selfLoop;
+ }
+ }
+
+ public static class TopologyLayoutResult {
+ public final List<TopologyLayoutNode> nodes;
+ public final List<TopologyLayoutEdge> edges;
+ public final int totalWidth;
+ public final int totalHeight;
+
+ public TopologyLayoutResult(
+ List<TopologyLayoutNode> nodes, List<TopologyLayoutEdge> edges,
+ int totalWidth, int totalHeight) {
+ this.nodes = nodes;
+ this.edges = edges;
+ this.totalWidth = totalWidth;
+ this.totalHeight = totalHeight;
+ }
+ }
+
+}
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
new file mode 100644
index 000000000000..1d6b773d0da2
--- /dev/null
+++ b/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyDiagramTest.java
@@ -0,0 +1,352 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.diagram;
+
+import java.util.List;
+
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyEdgeInfo;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutNode;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutResult;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyNodeInfo;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class TopologyDiagramTest {
+
+ @Test
+ void testLinearChainLayout() {
+ List<TopologyNodeInfo> nodes = List.of(
+ node("routeA", "direct:a", "route"),
+ node("routeB", "direct:b", "route"),
+ node("routeC", "direct:c", "route"));
+ List<TopologyEdgeInfo> edges = List.of(
+ edge("routeA", "routeB", "direct:b", "internal"),
+ edge("routeB", "routeC", "direct:c", "internal"));
+
+ TopologyLayoutEngine engine = new TopologyLayoutEngine();
+ TopologyLayoutResult result = engine.layout(nodes, edges);
+
+ assertNotNull(result);
+ assertEquals(3, result.nodes.size());
+ assertEquals(2, result.edges.size());
+
+ TopologyLayoutNode a = findNode(result, "routeA");
+ TopologyLayoutNode b = findNode(result, "routeB");
+ TopologyLayoutNode c = findNode(result, "routeC");
+
+ assertEquals(0, a.layer);
+ assertEquals(1, b.layer);
+ assertEquals(2, c.layer);
+ assertTrue(a.y < b.y);
+ assertTrue(b.y < c.y);
+ }
+
+ @Test
+ void testFanOutLayout() {
+ List<TopologyNodeInfo> nodes = List.of(
+ node("routeA", "timer:tick", "trigger"),
+ node("routeB", "direct:b", "route"),
+ node("routeC", "direct:c", "route"));
+ List<TopologyEdgeInfo> edges = List.of(
+ edge("routeA", "routeB", "direct:b", "internal"),
+ edge("routeA", "routeC", "direct:c", "internal"));
+
+ TopologyLayoutEngine engine = new TopologyLayoutEngine();
+ TopologyLayoutResult result = engine.layout(nodes, edges);
+
+ TopologyLayoutNode a = findNode(result, "routeA");
+ TopologyLayoutNode b = findNode(result, "routeB");
+ TopologyLayoutNode c = findNode(result, "routeC");
+
+ assertEquals(0, a.layer);
+ assertEquals(1, b.layer);
+ assertEquals(1, c.layer);
+ assertTrue(b.y > a.y);
+ assertTrue(c.y > a.y);
+ }
+
+ @Test
+ void testFanInLayout() {
+ List<TopologyNodeInfo> nodes = List.of(
+ node("routeA", "timer:a", "trigger"),
+ node("routeB", "timer:b", "trigger"),
+ node("routeC", "direct:c", "route"));
+ List<TopologyEdgeInfo> edges = List.of(
+ edge("routeA", "routeC", "direct:c", "internal"),
+ edge("routeB", "routeC", "direct:c", "internal"));
+
+ TopologyLayoutEngine engine = new TopologyLayoutEngine();
+ TopologyLayoutResult result = engine.layout(nodes, edges);
+
+ TopologyLayoutNode a = findNode(result, "routeA");
+ TopologyLayoutNode b = findNode(result, "routeB");
+ TopologyLayoutNode c = findNode(result, "routeC");
+
+ assertEquals(0, a.layer);
+ assertEquals(0, b.layer);
+ assertEquals(1, c.layer);
+ }
+
+ @Test
+ void testDiamondLayout() {
+ List<TopologyNodeInfo> nodes = List.of(
+ node("routeA", "timer:tick", "trigger"),
+ node("routeB", "direct:b", "route"),
+ node("routeC", "direct:c", "route"),
+ node("routeD", "direct:d", "route"));
+ List<TopologyEdgeInfo> edges = List.of(
+ edge("routeA", "routeB", "direct:b", "internal"),
+ edge("routeA", "routeC", "direct:c", "internal"),
+ edge("routeB", "routeD", "direct:d", "internal"),
+ edge("routeC", "routeD", "direct:d", "internal"));
+
+ TopologyLayoutEngine engine = new TopologyLayoutEngine();
+ TopologyLayoutResult result = engine.layout(nodes, edges);
+
+ TopologyLayoutNode a = findNode(result, "routeA");
+ TopologyLayoutNode b = findNode(result, "routeB");
+ TopologyLayoutNode c = findNode(result, "routeC");
+ TopologyLayoutNode d = findNode(result, "routeD");
+
+ assertEquals(0, a.layer);
+ assertEquals(1, b.layer);
+ assertEquals(1, c.layer);
+ assertEquals(2, d.layer);
+ }
+
+ @Test
+ void testTriggerNodesAtLayerZero() {
+ List<TopologyNodeInfo> nodes = List.of(
+ node("timer-route", "timer:tick", "trigger"),
+ node("process", "direct:process", "route"));
+ List<TopologyEdgeInfo> edges = List.of(
+ edge("timer-route", "process", "direct:process", "internal"));
+
+ TopologyLayoutEngine engine = new TopologyLayoutEngine();
+ TopologyLayoutResult result = engine.layout(nodes, edges);
+
+ TopologyLayoutNode timer = findNode(result, "timer-route");
+ assertEquals(0, timer.layer);
+ assertEquals("trigger", timer.nodeType);
+ }
+
+ @Test
+ void testIsolatedNodes() {
+ List<TopologyNodeInfo> nodes = List.of(
+ node("routeA", "timer:a", "trigger"),
+ node("routeB", "direct:b", "route"));
+ List<TopologyEdgeInfo> edges = List.of();
+
+ TopologyLayoutEngine engine = new TopologyLayoutEngine();
+ TopologyLayoutResult result = engine.layout(nodes, edges);
+
+ assertEquals(2, result.nodes.size());
+ assertEquals(0, result.edges.size());
+ }
+
+ @Test
+ void testSelfReferencingCycle() {
+ List<TopologyNodeInfo> nodes = List.of(
+ node("loop", "seda:loop", "route"));
+ List<TopologyEdgeInfo> edges = List.of(
+ edge("loop", "loop", "seda:loop", "internal"));
+
+ TopologyLayoutEngine engine = new TopologyLayoutEngine();
+ TopologyLayoutResult result = engine.layout(nodes, edges);
+
+ assertEquals(1, result.nodes.size());
+ assertEquals(1, result.edges.size());
+ assertTrue(result.edges.get(0).selfLoop);
+ }
+
+ @Test
+ void testAsciiRendering() {
+ List<TopologyNodeInfo> nodes = List.of(
+ node("routeA", "timer:tick", "trigger"),
+ node("routeB", "direct:process", "route"));
+ List<TopologyEdgeInfo> edges = List.of(
+ edge("routeA", "routeB", "direct:process", "internal"));
+
+ TopologyLayoutEngine engine = new TopologyLayoutEngine();
+ TopologyLayoutResult result = engine.layout(nodes, edges);
+
+ TopologyAsciiRenderer renderer = new TopologyAsciiRenderer(
+ TopologyLayoutEngine.DEFAULT_NODE_WIDTH * TopologyLayoutEngine.SCALE, false);
+ String output = renderer.renderDiagram(result);
+
+ assertNotNull(output);
+ assertFalse(output.isEmpty());
+ assertTrue(output.contains("routeA"));
+ assertTrue(output.contains("routeB"));
+ }
+
+ @Test
+ void testUnicodeRendering() {
+ List<TopologyNodeInfo> nodes = List.of(
+ node("routeA", "timer:tick", "trigger"),
+ node("routeB", "direct:process", "route"));
+ List<TopologyEdgeInfo> edges = List.of(
+ edge("routeA", "routeB", "direct:process", "internal"));
+
+ 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("┌"));
+ assertTrue(output.contains("routeA"));
+ }
+
+ @Test
+ void testExternalEdgeDashed() {
+ List<TopologyNodeInfo> nodes = List.of(
+ node("producer", "timer:tick", "trigger"),
+ node("consumer", "kafka:orders", "route"));
+ List<TopologyEdgeInfo> edges = List.of(
+ edge("producer", "consumer", "kafka:orders", "external"));
+
+ TopologyLayoutEngine engine = new TopologyLayoutEngine();
+ TopologyLayoutResult result = engine.layout(nodes, edges);
+
+ assertEquals(1, result.edges.size());
+ assertEquals("external", result.edges.get(0).connectionType);
+ }
+
+ @Test
+ void testEmptyTopology() {
+ TopologyLayoutEngine engine = new TopologyLayoutEngine();
+ TopologyLayoutResult result = engine.layout(List.of(), List.of());
+
+ assertNotNull(result);
+ assertEquals(0, result.nodes.size());
+ assertEquals(0, result.edges.size());
+ }
+
+ @Test
+ void testJsonParsing() {
+ String json = """
+ {
+ "nodes": [
+ {"routeId": "r1", "from": "timer:tick", "fromScheme": "timer", "nodeType": "trigger"},
+ {"routeId": "r2", "from": "direct:process", "fromScheme": "direct", "nodeType": "route"}
+ ],
+ "edges": [
+ {"fromRouteId": "r1", "toRouteId": "r2", "endpoint": "direct:process", "connectionType": "internal"}
+ ]
+ }
+ """;
+ org.apache.camel.util.json.JsonObject jo = new org.apache.camel.util.json.JsonObject();
+ try {
+ jo = (org.apache.camel.util.json.JsonObject) org.apache.camel.util.json.Jsoner.deserialize(json);
+ } catch (Exception e) {
+ // ignore
+ }
+
+ List<TopologyNodeInfo> nodes = TopologyHelper.parseNodes(jo);
+ List<TopologyEdgeInfo> edges = TopologyHelper.parseEdges(jo);
+
+ assertEquals(2, nodes.size());
+ assertEquals("r1", nodes.get(0).routeId);
+ assertEquals("trigger", nodes.get(0).nodeType);
+ assertEquals(1, edges.size());
+ assertEquals("internal", edges.get(0).connectionType);
+ }
+
+ @Test
+ void testWrapText() {
+ List<String> lines = TopologyAsciiRenderer.wrapText("short", 20);
+ assertEquals(1, lines.size());
+ assertEquals("short", lines.get(0));
+
+ List<String> wrapped = TopologyAsciiRenderer.wrapText("this is a longer text that needs wrapping", 15);
+ assertTrue(wrapped.size() > 1);
+ }
+
+ @Test
+ void testOrderProcessingTopology() {
+ List<TopologyNodeInfo> nodes = 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 = 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"));
+
+ TopologyLayoutEngine engine = new TopologyLayoutEngine();
+ TopologyLayoutResult result = engine.layout(nodes, edges);
+
+ assertEquals(7, result.nodes.size());
+ assertEquals(6, result.edges.size());
+
+ TopologyLayoutNode gen = findNode(result, "order-generator");
+ TopologyLayoutNode api = findNode(result, "order-api");
+ TopologyLayoutNode process = findNode(result, "process-order");
+
+ assertEquals(0, gen.layer);
+ assertEquals(0, api.layer);
+ assertTrue(process.layer > 0);
+
+ TopologyAsciiRenderer renderer = new TopologyAsciiRenderer(
+ TopologyLayoutEngine.DEFAULT_NODE_WIDTH * TopologyLayoutEngine.SCALE, true);
+ String output = renderer.renderDiagram(result);
+ assertNotNull(output);
+ assertTrue(output.contains("order-generator"));
+ assertTrue(output.contains("process-order"));
+ assertTrue(output.contains("fulfillment"));
+ }
+
+ private static TopologyNodeInfo node(String routeId, String from, String nodeType) {
+ TopologyNodeInfo n = new TopologyNodeInfo();
+ n.routeId = routeId;
+ n.from = from;
+ n.fromScheme = from.contains(":") ? from.substring(0, from.indexOf(':')) : from;
+ n.nodeType = nodeType;
+ return n;
+ }
+
+ private static TopologyEdgeInfo edge(String from, String to, String endpoint, String connType) {
+ TopologyEdgeInfo e = new TopologyEdgeInfo();
+ e.fromRouteId = from;
+ e.toRouteId = to;
+ e.endpoint = endpoint;
+ e.connectionType = connType;
+ return e;
+ }
+
+ private static TopologyLayoutNode findNode(TopologyLayoutResult result, String routeId) {
+ return result.nodes.stream()
+ .filter(n -> routeId.equals(n.routeId))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("Node not found: " + routeId));
+ }
+
+}
diff --git a/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyDumperTest.java b/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyDumperTest.java
new file mode 100644
index 000000000000..7e8787671faa
--- /dev/null
+++ b/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyDumperTest.java
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.diagram;
+
+import java.awt.image.BufferedImage;
+
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.spi.RouteDiagramDumper;
+import org.apache.camel.support.PluginHelper;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class TopologyDumperTest extends CamelTestSupport {
+
+ @BeforeAll
+ static void headless() {
+ System.setProperty("java.awt.headless", "true");
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("timer:tick?period=5000").routeId("generator")
+ .setBody(constant("order"))
+ .to("direct:process");
+
+ from("direct:process").routeId("processor")
+ .log("Processing: ${body}")
+ .to("direct:validate")
+ .to("mock:result");
+
+ from("direct:validate").routeId("validator")
+ .log("Validating: ${body}");
+
+ from("direct:isolated").routeId("isolated")
+ .to("mock:isolated");
+ }
+ };
+ }
+
+ @Test
+ void testTopologyAsciiArt() {
+ RouteDiagramDumper dumper = PluginHelper.getRouteDiagramDumper(context);
+ String ascii = dumper.dumpTopologyAsAsciiArt(180, false);
+
+ assertThat(ascii).isNotEmpty();
+ assertThat(ascii).contains("generator");
+ assertThat(ascii).contains("processor");
+ assertThat(ascii).contains("validator");
+ assertThat(ascii).contains("isolated");
+ }
+
+ @Test
+ void testTopologyUnicodeArt() {
+ RouteDiagramDumper dumper = PluginHelper.getRouteDiagramDumper(context);
+ String unicode = dumper.dumpTopologyAsAsciiArt(180, true);
+
+ assertThat(unicode).isNotEmpty();
+ assertThat(unicode).contains("generator");
+ assertThat(unicode).contains("┌");
+ }
+
+ @Test
+ void testTopologyImage() {
+ RouteDiagramDumper dumper = PluginHelper.getRouteDiagramDumper(context);
+ BufferedImage image = dumper.dumpTopologyAsImage(
+ RouteDiagramDumper.Theme.DARK, false, 180, 12);
+
+ assertThat(image).isNotNull();
+ assertThat(image.getWidth()).isGreaterThan(0);
+ assertThat(image.getHeight()).isGreaterThan(0);
+ }
+
+}
diff --git a/core/camel-api/src/main/java/org/apache/camel/spi/RouteDiagramDumper.java b/core/camel-api/src/main/java/org/apache/camel/spi/RouteDiagramDumper.java
index 94f06d2e43bc..c7192e4f9822 100644
--- a/core/camel-api/src/main/java/org/apache/camel/spi/RouteDiagramDumper.java
+++ b/core/camel-api/src/main/java/org/apache/camel/spi/RouteDiagramDumper.java
@@ -123,4 +123,30 @@ public interface RouteDiagramDumper {
throw new UnsupportedOperationException();
}
+ /**
+ * Dumps the route topology as ASCII art or Unicode box-drawing text
+ *
+ * @param nodeWidth the width in pixels of the node boxes
+ * @param unicode whether to use Unicode box-drawing characters
+ *
+ * @since 4.21
+ */
+ default String dumpTopologyAsAsciiArt(int nodeWidth, boolean unicode) {
+ throw new UnsupportedOperationException();
+ }
+
+ /**
+ * Dumps the route topology as a PNG image
+ *
+ * @param theme the coloring theme
+ * @param metrics whether to include live metric counters
+ * @param nodeWidth the width in pixels of the node boxes
+ * @param fontSize the font size
+ *
+ * @since 4.21
+ */
+ default BufferedImage dumpTopologyAsImage(Theme theme, boolean metrics, int nodeWidth, int fontSize) {
+ throw new UnsupportedOperationException();
+ }
+
}
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 88713e908bfd..35278a83bdaa 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
@@ -19,6 +19,8 @@ camel cmd route-topology [options]
[cols="2,5,1,2",options="header"]
|===
| Option | Description | Default | Type
+| `--box-width` | Width of diagram node boxes | 180 | int
+| `--diagram` | Display as visual topology diagram | | boolean
| `--json` | Output in JSON Format | | boolean
| `-h,--help` | Display the help and sub-commands | | boolean
|===
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 dc2e07a409a1..d1fd9a74bb92 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 57f9674229b9..a53802175172 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
@@ -19,6 +19,12 @@ package org.apache.camel.dsl.jbang.core.commands.action;
import java.nio.file.Path;
import java.util.List;
+import org.apache.camel.diagram.TopologyAsciiRenderer;
+import org.apache.camel.diagram.TopologyHelper;
+import org.apache.camel.diagram.TopologyLayoutEngine;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyEdgeInfo;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutResult;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyNodeInfo;
import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
import org.apache.camel.dsl.jbang.core.common.PathUtils;
import org.apache.camel.util.json.JsonArray;
@@ -32,7 +38,8 @@ import picocli.CommandLine.Command;
footer = {
"%nExamples:",
" camel cmd route-topology",
- " camel cmd route-topology --json" })
+ " camel cmd route-topology --json",
+ " camel cmd route-topology --diagram" })
public class CamelRouteTopologyAction extends ActionBaseCommand {
@CommandLine.Parameters(description = "Name or pid of a running Camel integration", arity = "0..1")
@@ -42,6 +49,14 @@ public class CamelRouteTopologyAction extends ActionBaseCommand {
description = "Output in JSON Format")
boolean jsonOutput;
+ @CommandLine.Option(names = { "--diagram" },
+ description = "Display as visual topology diagram")
+ boolean diagram;
+
+ @CommandLine.Option(names = { "--box-width" }, defaultValue = "180",
+ description = "Width of diagram node boxes")
+ int boxWidth = 180;
+
public CamelRouteTopologyAction(CamelJBangMain main) {
super(main);
}
@@ -68,6 +83,8 @@ public class CamelRouteTopologyAction extends ActionBaseCommand {
if (jsonOutput) {
String dump = Jsoner.prettyPrint(jo.toJson(), 2);
printer().println(dump);
+ } else if (diagram) {
+ printDiagram(jo);
} else {
printTopology(jo);
}
@@ -111,4 +128,17 @@ public class CamelRouteTopologyAction extends ActionBaseCommand {
}
}
+ private void printDiagram(JsonObject jo) {
+ List<TopologyNodeInfo> nodes = TopologyHelper.parseNodes(jo);
+ List<TopologyEdgeInfo> edges = TopologyHelper.parseEdges(jo);
+
+ TopologyLayoutEngine engine = new TopologyLayoutEngine(boxWidth);
+ TopologyLayoutResult result = engine.layout(nodes, edges);
+
+ TopologyAsciiRenderer renderer = new TopologyAsciiRenderer(
+ engine.getNodeWidth(), true);
+ String output = renderer.renderDiagram(result);
+ printer().print(output);
+ }
+
}
(camel) 02/03: CAMEL-23661: camel-diagram - Add --theme, --description, --metric to topology diagram
Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch CAMEL-23656/route-topology-service
in repository https://gitbox.apache.org/repos/asf/camel.git
commit 5179c46ad17328e4c530718c6b21ec364a36fb58
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Mon Jun 1 15:41:27 2026 +0200
CAMEL-23661: camel-diagram - Add --theme, --description, --metric to topology diagram
- Replace --diagram boolean with --theme (ascii/unicode/dark/light/transparent)
matching the route-diagram command pattern
- Add --output for saving diagrams to file (PNG or text)
- Add --description to show route descriptions instead of route IDs
for architecture-level diagrams with less noise
- Add --metric to include live exchange counters on nodes
- Add description field to TopologyNode SPI record
- Add metrics support directly in route-topology dev console via
ManagedRouteMBean (same pattern as route-structure)
- Extract TopologyImageRenderer as reusable class for PNG rendering
- Remove edge labels from topology diagrams to reduce visual clutter
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
.../camel/diagram/DefaultRouteDiagramDumper.java | 151 +---------------
.../camel/diagram/TopologyAsciiRenderer.java | 48 ++---
.../org/apache/camel/diagram/TopologyHelper.java | 3 +
.../camel/diagram/TopologyImageRenderer.java | 196 +++++++++++++++++++++
.../apache/camel/diagram/TopologyLayoutEngine.java | 9 +-
.../org/apache/camel/spi/RouteTopologyDumper.java | 13 +-
.../impl/console/RouteTopologyDevConsole.java | 21 +++
.../camel/impl/DefaultRouteTopologyDumper.java | 3 +-
.../camel-jbang-cmd-route-topology.adoc | 6 +-
.../camel/cli/connector/LocalCliConnector.java | 7 +-
.../META-INF/camel-jbang-commands-metadata.json | 2 +-
.../commands/action/CamelRouteTopologyAction.java | 122 +++++++++++--
12 files changed, 376 insertions(+), 205 deletions(-)
diff --git a/components/camel-diagram/src/main/java/org/apache/camel/diagram/DefaultRouteDiagramDumper.java b/components/camel-diagram/src/main/java/org/apache/camel/diagram/DefaultRouteDiagramDumper.java
index 7bc1964e3c09..f0ab7e3b228d 100644
--- a/components/camel-diagram/src/main/java/org/apache/camel/diagram/DefaultRouteDiagramDumper.java
+++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/DefaultRouteDiagramDumper.java
@@ -241,153 +241,10 @@ public class DefaultRouteDiagramDumper extends ServiceSupport implements CamelCo
TopologyLayoutEngine engine = new TopologyLayoutEngine(nodeWidth);
TopologyLayoutEngine.TopologyLayoutResult result = engine.layout(nodes, edges);
- // Render as image using topology PNG renderer
- return renderTopologyImage(result, theme != null ? theme.name() : "dark", fontSize, nodeWidth, metrics);
- }
-
- private static BufferedImage renderTopologyImage(
- TopologyLayoutEngine.TopologyLayoutResult result, String theme,
- int fontSize, int nodeWidth, boolean metrics) {
- int scale = RouteDiagramLayoutEngine.SCALE;
- int width = result.totalWidth + RouteDiagramLayoutEngine.PADDING;
- int height = result.totalHeight + RouteDiagramLayoutEngine.PADDING;
-
- width = Math.max(width, nodeWidth * scale + RouteDiagramLayoutEngine.PADDING * 2);
- height = Math.max(height, 100 * scale);
-
- RouteDiagramRenderer.DiagramColors colors = RouteDiagramRenderer.DiagramColors.parse(theme.toLowerCase());
- java.awt.Color bgColor = colors.getBg();
-
- int imageType = bgColor != null ? BufferedImage.TYPE_INT_RGB : BufferedImage.TYPE_INT_ARGB;
- BufferedImage image = new BufferedImage(
- Math.min(width, 16384), Math.min(height, 16384), imageType);
-
- java.awt.Graphics2D g = image.createGraphics();
- g.setRenderingHint(
- java.awt.RenderingHints.KEY_ANTIALIASING, java.awt.RenderingHints.VALUE_ANTIALIAS_ON);
- g.setRenderingHint(
- java.awt.RenderingHints.KEY_TEXT_ANTIALIASING, java.awt.RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
-
- if (bgColor != null) {
- g.setColor(bgColor);
- g.fillRect(0, 0, image.getWidth(), image.getHeight());
- }
-
- int fontSizeScaled = fontSize * scale;
- java.awt.Font font = new java.awt.Font(java.awt.Font.SANS_SERIF, java.awt.Font.PLAIN, fontSizeScaled);
- g.setFont(font);
- java.awt.FontMetrics fm = g.getFontMetrics();
-
- int arc = 14 * scale;
- float strokeWidth = 1.5f * scale;
- int arrowSize = 6 * scale;
- int nw = nodeWidth * scale;
-
- // Draw edges
- for (TopologyLayoutEngine.TopologyLayoutEdge edge : result.edges) {
- if (edge.selfLoop) {
- continue;
- }
-
- boolean dashed = "external".equals(edge.connectionType);
- java.awt.Stroke stroke;
- if (dashed) {
- stroke = new java.awt.BasicStroke(
- strokeWidth, java.awt.BasicStroke.CAP_BUTT, java.awt.BasicStroke.JOIN_MITER,
- 10.0f, new float[] { 10f, 5f }, 0.0f);
- } else {
- stroke = new java.awt.BasicStroke(strokeWidth);
- }
- g.setStroke(stroke);
- g.setColor(colors.getArrow());
-
- int fromCx = edge.from.x + nw / 2;
- int fromBy = edge.from.y + edge.from.height;
- int toCx = edge.to.x + nw / 2;
- int toTy = edge.to.y;
-
- if (fromCx == toCx) {
- g.drawLine(fromCx, fromBy, toCx, toTy - arrowSize / 2);
- } else {
- int midY = fromBy + (toTy - fromBy) / 2;
- g.drawLine(fromCx, fromBy, fromCx, midY);
- g.drawLine(fromCx, midY, toCx, midY);
- g.drawLine(toCx, midY, toCx, toTy - arrowSize / 2);
- }
-
- // Arrow head
- int[] xPoints = { toCx - arrowSize, toCx + arrowSize, toCx };
- int[] yPoints = { toTy - arrowSize, toTy - arrowSize, toTy };
- g.fillPolygon(xPoints, yPoints, 3);
-
- // Edge label
- if (edge.endpoint != null) {
- g.setColor(colors.getText());
- java.awt.Font labelFont = font.deriveFont((float) (fontSizeScaled * 0.85));
- g.setFont(labelFont);
- int labelX = Math.min(fromCx, toCx) + Math.abs(fromCx - toCx) / 2;
- int labelY = fromBy + (toTy - fromBy) / 2 - fontSizeScaled / 2;
- String label = edge.endpoint;
- int labelWidth = g.getFontMetrics().stringWidth(label);
- g.drawString(label, labelX - labelWidth / 2, labelY);
- g.setFont(font);
- }
- }
-
- // Draw nodes
- for (TopologyLayoutEngine.TopologyLayoutNode node : result.nodes) {
- java.awt.Color nodeColor;
- if ("trigger".equals(node.nodeType)) {
- nodeColor = colors.getNodeFrom();
- } else {
- nodeColor = colors.getNodeDefault();
- }
-
- // Node box
- g.setColor(nodeColor);
- g.setStroke(new java.awt.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);
-
- // Text
- g.setColor(colors.getText());
- String line1 = node.routeId;
- String line2 = "(" + node.from + ")";
- int lineHeight = fm.getHeight();
- int textY = node.y + (node.height - 2 * lineHeight) / 2 + fm.getAscent();
-
- int line1Width = fm.stringWidth(line1);
- g.drawString(line1, node.x + (nw - line1Width) / 2, textY);
-
- java.awt.Font smallFont = font.deriveFont((float) (fontSizeScaled * 0.85));
- g.setFont(smallFont);
- int line2Width = g.getFontMetrics().stringWidth(line2);
- g.drawString(line2, node.x + (nw - line2Width) / 2, textY + lineHeight);
- g.setFont(font);
-
- // Metrics
- if (metrics && node.exchangesTotal > 0) {
- long ok = node.exchangesTotal - node.exchangesFailed;
- java.awt.Font metricsFont = font.deriveFont((float) (fontSizeScaled * 0.75));
- g.setFont(metricsFont);
- if (ok > 0) {
- g.setColor(new java.awt.Color(0x43a047));
- String okStr = String.valueOf(ok);
- g.drawString(okStr, node.x + nw / 2 - g.getFontMetrics().stringWidth(okStr) - 4, textY + 2 * lineHeight);
- }
- if (node.exchangesFailed > 0) {
- g.setColor(new java.awt.Color(0xe53935));
- String failStr = String.valueOf(node.exchangesFailed);
- g.drawString(failStr, node.x + nw / 2 + 4, textY + 2 * lineHeight);
- }
- g.setFont(font);
- }
- }
-
- g.dispose();
- return image;
+ // Render as image
+ String themeStr = theme != null ? theme.name() : "dark";
+ RouteDiagramRenderer.DiagramColors colors = RouteDiagramRenderer.DiagramColors.parse(themeStr.toLowerCase());
+ return TopologyImageRenderer.renderImage(result, colors, fontSize, nodeWidth, metrics, false);
}
}
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 b324edb095ec..8bba2d3b3780 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
@@ -51,6 +51,7 @@ public class TopologyAsciiRenderer {
private final int boxWidth;
private final boolean unicode;
private final boolean metrics;
+ private final boolean showDescription;
private final List<CounterPos> counterPositions = new ArrayList<>();
enum CounterType {
@@ -64,14 +65,15 @@ public class TopologyAsciiRenderer {
}
public TopologyAsciiRenderer(int nodeWidth, boolean unicode) {
- this(nodeWidth, unicode, false);
+ this(nodeWidth, unicode, false, false);
}
- public TopologyAsciiRenderer(int nodeWidth, boolean unicode, boolean metrics) {
+ public TopologyAsciiRenderer(int nodeWidth, boolean unicode, boolean metrics, boolean showDescription) {
this.nodeWidth = nodeWidth;
this.boxWidth = Math.max(MIN_BOX_WIDTH, nodeWidth / X_DIVISOR);
this.unicode = unicode;
this.metrics = metrics;
+ this.showDescription = showDescription;
}
public int getBoxWidth() {
@@ -118,12 +120,19 @@ public class TopologyAsciiRenderer {
int col = toCol(node.x);
int row = toRow(node.y);
- String line1 = node.routeId;
- String line2 = "(" + node.from + ")";
+ String line1;
+ if (showDescription && node.description != null && !node.description.isBlank()) {
+ line1 = node.description;
+ } else {
+ line1 = node.routeId;
+ }
List<String> lines = new ArrayList<>();
lines.addAll(wrapText(line1, boxWidth - 4));
- lines.addAll(wrapText(line2, boxWidth - 4));
+ if (!showDescription) {
+ String line2 = "(" + node.from + ")";
+ lines.addAll(wrapText(line2, boxWidth - 4));
+ }
if (metrics && (node.exchangesTotal > 0 || node.exchangesFailed > 0)) {
long ok = node.exchangesTotal - node.exchangesFailed;
@@ -251,28 +260,6 @@ public class TopologyAsciiRenderer {
setChar(grid, toTop - 1, toCx, arrow);
}
- // Draw edge label at midpoint
- String label = edge.endpoint;
- if (label != null && !label.isEmpty()) {
- int labelRow;
- int labelCol;
- if (fromCx == toCx) {
- labelRow = fromBottom + (toTop - fromBottom) / 2;
- labelCol = fromCx + 2;
- } else {
- int midRow = fromBottom + (toTop - fromBottom) / 2;
- labelRow = midRow - 1;
- labelCol = Math.min(fromCx, toCx) + 1;
- }
- if (label.length() > boxWidth) {
- label = label.substring(0, boxWidth - 3) + "...";
- }
- drawText(grid, labelRow, labelCol, label);
-
- if (dashed) {
- counterPositions.add(new CounterPos(labelRow, labelCol, label.length(), CounterType.EXTERNAL));
- }
- }
}
private void drawSelfLoop(char[][] grid, TopologyLayoutEdge edge) {
@@ -292,13 +279,6 @@ public class TopologyAsciiRenderer {
setChar(grid, topRow, col + 2, unicode ? UNI_TR : '+');
setChar(grid, botRow, col + 2, unicode ? UNI_BR : '+');
- String label = edge.endpoint;
- if (label != null && !label.isEmpty()) {
- if (label.length() > 10) {
- label = label.substring(0, 7) + "...";
- }
- drawText(grid, topRow + 1, col + 4, label);
- }
}
private int boxHeight(TopologyLayoutNode node) {
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 49e2722e8c6f..64d54f8a5f03 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
@@ -43,9 +43,12 @@ public final class TopologyHelper {
JsonObject no = arr.getJsonObject(i);
TopologyNodeInfo node = new TopologyNodeInfo();
node.routeId = no.getString("routeId");
+ node.description = no.getString("description");
node.from = no.getString("from");
node.fromScheme = no.getString("fromScheme");
node.nodeType = no.getStringOrDefault("nodeType", "route");
+ node.exchangesTotal = no.getLongOrDefault("exchangesTotal", 0);
+ node.exchangesFailed = no.getLongOrDefault("exchangesFailed", 0);
nodes.add(node);
}
return nodes;
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
new file mode 100644
index 000000000000..b8491af25c4a
--- /dev/null
+++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyImageRenderer.java
@@ -0,0 +1,196 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.diagram;
+
+import java.awt.BasicStroke;
+import java.awt.Color;
+import java.awt.Font;
+import java.awt.FontMetrics;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+
+import org.apache.camel.diagram.RouteDiagramRenderer.DiagramColors;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutEdge;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutNode;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutResult;
+
+/**
+ * Renders topology diagrams as PNG images.
+ */
+public class TopologyImageRenderer {
+
+ private TopologyImageRenderer() {
+ }
+
+ public static BufferedImage renderImage(
+ TopologyLayoutResult result, DiagramColors colors,
+ int fontSize, int nodeWidth, boolean metrics, boolean showDescription) {
+
+ int scale = RouteDiagramLayoutEngine.SCALE;
+ int width = result.totalWidth + RouteDiagramLayoutEngine.PADDING;
+ int height = result.totalHeight + RouteDiagramLayoutEngine.PADDING;
+
+ width = Math.max(width, nodeWidth * scale + RouteDiagramLayoutEngine.PADDING * 2);
+ height = Math.max(height, 100 * scale);
+
+ Color bgColor = colors.getBg();
+
+ int imageType = bgColor != null ? BufferedImage.TYPE_INT_RGB : BufferedImage.TYPE_INT_ARGB;
+ BufferedImage image = new BufferedImage(
+ Math.min(width, 16384), Math.min(height, 16384), imageType);
+
+ Graphics2D g = image.createGraphics();
+ g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+ g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+
+ if (bgColor != null) {
+ g.setColor(bgColor);
+ g.fillRect(0, 0, image.getWidth(), image.getHeight());
+ }
+
+ int fontSizeScaled = fontSize * scale;
+ Font font = new Font(Font.SANS_SERIF, Font.PLAIN, fontSizeScaled);
+ g.setFont(font);
+ FontMetrics fm = g.getFontMetrics();
+
+ int arc = 14 * scale;
+ float strokeWidth = 1.5f * scale;
+ int arrowSize = 6 * scale;
+ int nw = nodeWidth * scale;
+
+ drawEdges(g, result, colors, nw, strokeWidth, arrowSize);
+ drawNodes(g, result, colors, font, fm, nw, fontSizeScaled, arc, strokeWidth, metrics, showDescription);
+
+ g.dispose();
+ return image;
+ }
+
+ private static void drawEdges(
+ Graphics2D g, TopologyLayoutResult result, DiagramColors colors,
+ int nw, float strokeWidth, int arrowSize) {
+
+ for (TopologyLayoutEdge edge : result.edges) {
+ if (edge.selfLoop) {
+ continue;
+ }
+
+ boolean dashed = "external".equals(edge.connectionType);
+ java.awt.Stroke stroke;
+ if (dashed) {
+ stroke = new BasicStroke(
+ strokeWidth, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER,
+ 10.0f, new float[] { 10f, 5f }, 0.0f);
+ } else {
+ stroke = new BasicStroke(strokeWidth);
+ }
+ g.setStroke(stroke);
+ g.setColor(colors.getArrow());
+
+ int fromCx = edge.from.x + nw / 2;
+ int fromBy = edge.from.y + edge.from.height;
+ int toCx = edge.to.x + nw / 2;
+ int toTy = edge.to.y;
+
+ if (fromCx == toCx) {
+ g.drawLine(fromCx, fromBy, toCx, toTy - arrowSize / 2);
+ } else {
+ int midY = fromBy + (toTy - fromBy) / 2;
+ g.drawLine(fromCx, fromBy, fromCx, midY);
+ g.drawLine(fromCx, midY, toCx, midY);
+ g.drawLine(toCx, midY, toCx, toTy - arrowSize / 2);
+ }
+
+ // Arrow head
+ int[] xPoints = { toCx - arrowSize, toCx + arrowSize, toCx };
+ int[] yPoints = { toTy - arrowSize, toTy - arrowSize, toTy };
+ g.fillPolygon(xPoints, yPoints, 3);
+ }
+ }
+
+ private static void drawNodes(
+ Graphics2D g, TopologyLayoutResult result, DiagramColors colors,
+ Font font, FontMetrics fm, int nw, int fontSizeScaled,
+ int arc, float strokeWidth, boolean metrics, boolean showDescription) {
+
+ for (TopologyLayoutNode node : result.nodes) {
+ Color nodeColor;
+ if ("trigger".equals(node.nodeType)) {
+ nodeColor = colors.getNodeFrom();
+ } else {
+ nodeColor = colors.getNodeDefault();
+ }
+
+ // Node box
+ g.setColor(nodeColor);
+ 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);
+
+ // Text
+ g.setColor(colors.getText());
+ String line1;
+ if (showDescription && node.description != null && !node.description.isBlank()) {
+ line1 = node.description;
+ } else {
+ line1 = node.routeId;
+ }
+ int lineHeight = fm.getHeight();
+ int textY;
+
+ if (showDescription) {
+ textY = node.y + (node.height - lineHeight) / 2 + fm.getAscent();
+ int line1Width = fm.stringWidth(line1);
+ g.drawString(line1, node.x + (nw - line1Width) / 2, textY);
+ } else {
+ String line2 = "(" + node.from + ")";
+ textY = node.y + (node.height - 2 * lineHeight) / 2 + fm.getAscent();
+
+ int line1Width = fm.stringWidth(line1);
+ g.drawString(line1, node.x + (nw - line1Width) / 2, textY);
+
+ Font smallFont = font.deriveFont((float) (fontSizeScaled * 0.85));
+ g.setFont(smallFont);
+ int line2Width = g.getFontMetrics().stringWidth(line2);
+ g.drawString(line2, node.x + (nw - line2Width) / 2, textY + lineHeight);
+ g.setFont(font);
+ }
+
+ // Metrics
+ if (metrics && node.exchangesTotal > 0) {
+ long ok = node.exchangesTotal - node.exchangesFailed;
+ Font metricsFont = font.deriveFont((float) (fontSizeScaled * 0.75));
+ g.setFont(metricsFont);
+ if (ok > 0) {
+ g.setColor(new Color(0x43a047));
+ String okStr = String.valueOf(ok);
+ g.drawString(okStr, node.x + nw / 2 - g.getFontMetrics().stringWidth(okStr) - 4,
+ textY + 2 * lineHeight);
+ }
+ if (node.exchangesFailed > 0) {
+ g.setColor(new Color(0xe53935));
+ String failStr = String.valueOf(node.exchangesFailed);
+ g.drawString(failStr, node.x + nw / 2 + 4, textY + 2 * lineHeight);
+ }
+ g.setFont(font);
+ }
+ }
+ }
+
+}
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 49ed81ef506a..4fba5a9aa22d 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
@@ -245,8 +245,10 @@ public class TopologyLayoutEngine {
TopologyNodeInfo info = nodeMap.get(routeId);
int x = startX + i * (nodeWidth + H_GAP);
TopologyLayoutNode ln = new TopologyLayoutNode(
- routeId, info.from, info.nodeType, info.connectionType,
+ routeId, info.description, info.from, info.nodeType, info.connectionType,
x, y, nodeWidth, nodeHeight, layerIdx);
+ ln.exchangesTotal = info.exchangesTotal;
+ ln.exchangesFailed = info.exchangesFailed;
layoutNodes.put(routeId, ln);
}
}
@@ -257,6 +259,7 @@ public class TopologyLayoutEngine {
// Input data structures
public static class TopologyNodeInfo {
public String routeId;
+ public String description;
public String from;
public String fromScheme;
public String nodeType;
@@ -275,6 +278,7 @@ public class TopologyLayoutEngine {
// Layout output
public static class TopologyLayoutNode {
public final String routeId;
+ public final String description;
public final String from;
public final String nodeType;
public final String connectionType;
@@ -287,9 +291,10 @@ public class TopologyLayoutEngine {
public long exchangesFailed;
public TopologyLayoutNode(
- String routeId, String from, String nodeType, String connectionType,
+ String routeId, String description, String from, String nodeType, String connectionType,
int x, int y, int width, int height, int layer) {
this.routeId = routeId;
+ this.description = description;
this.from = from;
this.nodeType = nodeType;
this.connectionType = connectionType;
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 d7d6af83f953..b78f99e425ff 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
@@ -35,13 +35,14 @@ public interface RouteTopologyDumper {
/**
* A node in the topology representing a route.
*
- * @param routeId the route id
- * @param from the input endpoint URI (scheme:context-path, query parameters stripped)
- * @param fromScheme the component scheme of the input endpoint
- * @param nodeType the type of node: "route" for regular routes, "trigger" for timer/quartz/cron/scheduler
- * @since 4.21
+ * @param routeId the route id
+ * @param description the route description (may be null)
+ * @param from the input endpoint URI (scheme:context-path, query parameters stripped)
+ * @param fromScheme the component scheme of the input endpoint
+ * @param nodeType the type of node: "route" for regular routes, "trigger" for timer/quartz/cron/scheduler
+ * @since 4.21
*/
- record TopologyNode(String routeId, String from, String fromScheme, String nodeType) {
+ record TopologyNode(String routeId, String description, String from, String fromScheme, String nodeType) {
}
/**
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 6dcb2c099193..112d35e60193 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
@@ -18,6 +18,8 @@ package org.apache.camel.impl.console;
import java.util.Map;
+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.TopologyNode;
@@ -31,6 +33,8 @@ import org.apache.camel.util.json.JsonObject;
@DevConsole(name = "route-topology", description = "Route topology showing inter-route connections")
public class RouteTopologyDevConsole extends AbstractDevConsole {
+ private static final String METRIC = "metric";
+
public RouteTopologyDevConsole() {
super("camel", "route-topology", "Route Topology", "Route topology showing inter-route connections");
}
@@ -69,13 +73,30 @@ public class RouteTopologyDevConsole extends AbstractDevConsole {
}
TopologyResult result = dumper.dumpTopology(getCamelContext());
+ boolean metric = "true".equals(options.get(METRIC));
+ ManagedCamelContext mcc = metric
+ ? getCamelContext().getCamelContextExtension().getContextPlugin(ManagedCamelContext.class)
+ : null;
+
JsonArray nodesArr = new JsonArray();
for (TopologyNode node : result.nodes()) {
JsonObject jo = new JsonObject();
jo.put("routeId", node.routeId());
+ if (node.description() != null) {
+ jo.put("description", node.description());
+ }
jo.put("from", node.from());
jo.put("fromScheme", node.fromScheme());
jo.put("nodeType", node.nodeType());
+
+ if (mcc != null) {
+ ManagedRouteMBean mrb = mcc.getManagedRoute(node.routeId());
+ if (mrb != null) {
+ jo.put("exchangesTotal", mrb.getExchangesTotal());
+ jo.put("exchangesFailed", mrb.getExchangesFailed());
+ }
+ }
+
nodesArr.add(jo);
}
root.put("nodes", nodesArr);
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 112aba2539ed..246095167a0f 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
@@ -51,8 +51,9 @@ public class DefaultRouteTopologyDumper implements RouteTopologyDumper {
String normalizedInput = URISupport.stripQuery(inputUri);
String scheme = extractScheme(normalizedInput);
String nodeType = TRIGGER_SCHEMES.contains(scheme) ? "trigger" : "route";
+ String description = rd.getDescriptionText();
- nodes.add(new TopologyNode(rd.getRouteId(), normalizedInput, scheme, nodeType));
+ nodes.add(new TopologyNode(rd.getRouteId(), description, normalizedInput, scheme, nodeType));
inputUriToRouteIds
.computeIfAbsent(normalizedInput, k -> new ArrayList<>())
.add(rd.getRouteId());
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 35278a83bdaa..b8d3f03e70f8 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
@@ -20,8 +20,12 @@ camel cmd route-topology [options]
|===
| Option | Description | Default | Type
| `--box-width` | Width of diagram node boxes | 180 | int
-| `--diagram` | Display as visual topology diagram | | boolean
+| `--description` | Prefer route description over route id in node labels | | 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
+| `--output` | Save diagram to a file (PNG for image themes, text for ascii theme) | | String
+| `--theme` | Color theme preset (dark, light, transparent, ascii, unicode) or custom colors (e.g. bg=#1e1e1e:from=#2e7d32:to=#1565c0). Use ascii/unicode for plain text output. | | String
| `-h,--help` | Display the help and sub-commands | | 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 9f849c3e9767..5bf7dc0e6da6 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
@@ -346,7 +346,7 @@ public class LocalCliConnector extends ServiceSupport implements CliConnector, C
} else if ("route-structure".equals(action)) {
doActionRouteStructureTask(root);
} else if ("route-topology".equals(action)) {
- doActionRouteTopologyTask();
+ doActionRouteTopologyTask(root);
} else if ("route-controller".equals(action)) {
doActionRouteControllerTask(root);
} else if ("startup-recorder".equals(action)) {
@@ -750,11 +750,12 @@ public class LocalCliConnector extends ServiceSupport implements CliConnector, C
}
}
- private void doActionRouteTopologyTask() throws Exception {
+ private void doActionRouteTopologyTask(JsonObject root) throws Exception {
DevConsole dc = camelContext.getCamelContextExtension().getContextPlugin(DevConsoleRegistry.class)
.resolveById("route-topology");
if (dc != null) {
- JsonObject json = (JsonObject) dc.call(DevConsole.MediaType.JSON, Map.of());
+ String metric = root.getStringOrDefault("metric", "false");
+ JsonObject json = (JsonObject) dc.call(DevConsole.MediaType.JSON, Map.of("metric", metric));
LOG.trace("Updating output file: {}", outputFile);
IOHelper.writeText(json.toJson(), outputFile);
} else {
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 d1fd9a74bb92..c03c11cf0cbf 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 a53802175172..db9bb52aa869 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
@@ -16,11 +16,18 @@
*/
package org.apache.camel.dsl.jbang.core.commands.action;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
+import javax.imageio.ImageIO;
+
+import org.apache.camel.diagram.RouteDiagramRenderer.DiagramColors;
import org.apache.camel.diagram.TopologyAsciiRenderer;
import org.apache.camel.diagram.TopologyHelper;
+import org.apache.camel.diagram.TopologyImageRenderer;
import org.apache.camel.diagram.TopologyLayoutEngine;
import org.apache.camel.diagram.TopologyLayoutEngine.TopologyEdgeInfo;
import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutResult;
@@ -30,6 +37,10 @@ import org.apache.camel.dsl.jbang.core.common.PathUtils;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
import org.apache.camel.util.json.Jsoner;
+import org.jline.terminal.Terminal;
+import org.jline.terminal.TerminalBuilder;
+import org.jline.terminal.impl.TerminalGraphics;
+import org.jline.terminal.impl.TerminalGraphicsManager;
import picocli.CommandLine;
import picocli.CommandLine.Command;
@@ -39,7 +50,9 @@ import picocli.CommandLine.Command;
"%nExamples:",
" camel cmd route-topology",
" camel cmd route-topology --json",
- " camel cmd route-topology --diagram" })
+ " camel cmd route-topology --theme=unicode",
+ " camel cmd route-topology --theme=dark",
+ " camel cmd route-topology --theme=dark --output=topology.png" })
public class CamelRouteTopologyAction extends ActionBaseCommand {
@CommandLine.Parameters(description = "Name or pid of a running Camel integration", arity = "0..1")
@@ -49,14 +62,32 @@ public class CamelRouteTopologyAction extends ActionBaseCommand {
description = "Output in JSON Format")
boolean jsonOutput;
- @CommandLine.Option(names = { "--diagram" },
- description = "Display as visual topology diagram")
- boolean diagram;
+ @CommandLine.Option(names = { "--theme" },
+ description = "Color theme preset (dark, light, transparent, ascii, unicode) or custom colors "
+ + "(e.g. bg=#1e1e1e:from=#2e7d32:to=#1565c0). "
+ + "Use ascii/unicode for plain text output.")
+ String theme;
+
+ @CommandLine.Option(names = { "--output" },
+ description = "Save diagram to a file (PNG for image themes, text for ascii theme)")
+ String output;
@CommandLine.Option(names = { "--box-width" }, defaultValue = "180",
description = "Width of diagram node boxes")
int boxWidth = 180;
+ @CommandLine.Option(names = { "--font-size" },
+ description = "Font size in logical pixels for node text", defaultValue = "12")
+ int fontSize;
+
+ @CommandLine.Option(names = { "--description" },
+ description = "Prefer route description over route id in node labels")
+ boolean description;
+
+ @CommandLine.Option(names = { "--metric" }, defaultValue = "true",
+ description = "Whether to include live metrics")
+ boolean metric;
+
public CamelRouteTopologyAction(CamelJBangMain main) {
super(main);
}
@@ -76,6 +107,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));
});
JsonObject jo = getJsonObject(outputFile);
@@ -83,8 +115,12 @@ public class CamelRouteTopologyAction extends ActionBaseCommand {
if (jsonOutput) {
String dump = Jsoner.prettyPrint(jo.toJson(), 2);
printer().println(dump);
- } else if (diagram) {
- printDiagram(jo);
+ } else if (theme != null) {
+ if (isTextTheme()) {
+ printTextDiagram(jo);
+ } else {
+ printImageDiagram(jo);
+ }
} else {
printTopology(jo);
}
@@ -128,7 +164,7 @@ public class CamelRouteTopologyAction extends ActionBaseCommand {
}
}
- private void printDiagram(JsonObject jo) {
+ private void printTextDiagram(JsonObject jo) throws Exception {
List<TopologyNodeInfo> nodes = TopologyHelper.parseNodes(jo);
List<TopologyEdgeInfo> edges = TopologyHelper.parseEdges(jo);
@@ -136,9 +172,75 @@ public class CamelRouteTopologyAction extends ActionBaseCommand {
TopologyLayoutResult result = engine.layout(nodes, edges);
TopologyAsciiRenderer renderer = new TopologyAsciiRenderer(
- engine.getNodeWidth(), true);
- String output = renderer.renderDiagram(result);
- printer().print(output);
+ engine.getNodeWidth(), isUnicodeTheme(), metric, description);
+ String text = renderer.renderDiagram(result);
+
+ if (output != null) {
+ String fileName = output.endsWith(".png")
+ ? output.substring(0, output.length() - 4) + ".txt" : output;
+ File file = new File(fileName);
+ File parentDir = file.getParentFile();
+ if (parentDir != null) {
+ parentDir.mkdirs();
+ }
+ Files.writeString(file.toPath(), text);
+ printer().println("Diagram saved to: " + file.getAbsolutePath());
+ } else {
+ printer().print(text);
+ }
+ }
+
+ private void printImageDiagram(JsonObject jo) throws Exception {
+ System.setProperty("java.awt.headless", "true");
+
+ List<TopologyNodeInfo> nodes = TopologyHelper.parseNodes(jo);
+ List<TopologyEdgeInfo> edges = TopologyHelper.parseEdges(jo);
+
+ TopologyLayoutEngine engine = new TopologyLayoutEngine(boxWidth);
+ TopologyLayoutResult result = engine.layout(nodes, edges);
+
+ String colorSpec = System.getenv("DIAGRAM_COLORS");
+ DiagramColors colors = DiagramColors.parse(colorSpec != null ? colorSpec : theme);
+
+ BufferedImage image = TopologyImageRenderer.renderImage(
+ result, colors, fontSize, boxWidth, metric, description);
+
+ if (output != null) {
+ File file = new File(output);
+ File parentDir = file.getParentFile();
+ if (parentDir != null) {
+ parentDir.mkdirs();
+ }
+ ImageIO.write(image, "PNG", file);
+ printer().println("Diagram saved to: " + file.getAbsolutePath());
+ } else {
+ Terminal terminal = TerminalBuilder.builder().system(true).build();
+ try {
+ TerminalGraphics terminalGraphics = TerminalGraphicsManager.getBestProtocol(terminal).orElse(null);
+ if (terminalGraphics == null) {
+ printer().println("Terminal does not support graphics protocols (Kitty, iTerm2, or Sixel).");
+ printer().println(
+ "Try running in a supported terminal: Kitty, iTerm2, WezTerm, Ghostty, or VS Code.");
+ printer().println("Or use --theme=ascii or --theme=unicode for plain text output.");
+ return;
+ }
+ TerminalGraphics.ImageOptions opts = new TerminalGraphics.ImageOptions()
+ .preserveAspectRatio(true);
+ terminalGraphics.displayImage(terminal, image, opts);
+ terminal.writer().println();
+ terminal.flush();
+ } finally {
+ terminal.close();
+ }
+ }
+ }
+
+ private boolean isTextTheme() {
+ return "ascii".equalsIgnoreCase(theme) || "unicode".equalsIgnoreCase(theme);
+ }
+
+ private boolean isUnicodeTheme() {
+ return "unicode".equalsIgnoreCase(theme);
}
}