This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 7582a86772f3 CAMEL-23808: camel-diagram - Topology dev console to
render HTML and PNG visual diagram (#24265)
7582a86772f3 is described below
commit 7582a86772f397c2dc24bb04b2b4edd6c64b8dbe
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Jun 26 10:36:53 2026 +0200
CAMEL-23808: camel-diagram - Topology dev console to render HTML and PNG
visual diagram (#24265)
* CAMEL-23808: camel-diagram - Topology dev console to render HTML and PNG
visual diagram
Co-Authored-By: Claude <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
* CAMEL-23808: Document topology HTML diagram in developer console docs
Co-Authored-By: Claude <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---------
Signed-off-by: Claus Ibsen <[email protected]>
Co-authored-by: Claude <[email protected]>
---
.../camel/diagram/DefaultRouteDiagramDumper.java | 18 +-
.../apache/camel/diagram/DiagramDevConsole.java | 56 +-
.../camel/diagram/camel-topology-diagram.js | 590 +++++++++++++++++++++
.../camel/diagram/DiagramDevConsoleTest.java | 37 ++
.../camel/diagram/WebComponentBundleTest.java | 43 ++
.../org/apache/camel/spi/RouteDiagramDumper.java | 28 +
.../modules/ROOT/pages/route-diagram.adoc | 16 +-
7 files changed, 770 insertions(+), 18 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 4c9af5b1c3d4..0e6d30b37998 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
@@ -209,16 +209,21 @@ public class DefaultRouteDiagramDumper extends
ServiceSupport implements CamelCo
}
@Override
- public String dumpTopologyAsAsciiArt(int nodeWidth, boolean unicode) {
+ public String dumpTopologyAsAsciiArt(int nodeWidth, boolean unicode,
boolean external) {
DevConsole dc =
getCamelContext().getCamelContextExtension().getContextPlugin(DevConsoleRegistry.class)
.resolveById("route-topology");
if (dc == null) {
return "";
}
- JsonObject root = (JsonObject) dc.call(DevConsole.MediaType.JSON);
+ JsonObject root
+ = (JsonObject) dc.call(DevConsole.MediaType.JSON,
Map.of("external", String.valueOf(external)));
var nodes = TopologyHelper.parseNodes(root);
var edges = TopologyHelper.parseEdges(root);
+ if (external) {
+ TopologyHelper.addExternalEndpoints(nodes, edges, root);
+ TopologyHelper.expandExternalEdges(nodes, edges);
+ }
TopologyLayoutEngine engine = new TopologyLayoutEngine(nodeWidth);
TopologyLayoutEngine.TopologyLayoutResult result =
engine.layout(nodes, edges);
@@ -229,16 +234,21 @@ public class DefaultRouteDiagramDumper extends
ServiceSupport implements CamelCo
}
@Override
- public BufferedImage dumpTopologyAsImage(Theme theme, boolean metrics, int
nodeWidth, int fontSize) {
+ public BufferedImage dumpTopologyAsImage(Theme theme, boolean metrics, int
nodeWidth, int fontSize, boolean external) {
DevConsole dc =
getCamelContext().getCamelContextExtension().getContextPlugin(DevConsoleRegistry.class)
.resolveById("route-topology");
if (dc == null) {
return null;
}
- JsonObject root = (JsonObject) dc.call(DevConsole.MediaType.JSON);
+ JsonObject root
+ = (JsonObject) dc.call(DevConsole.MediaType.JSON,
Map.of("external", String.valueOf(external)));
var nodes = TopologyHelper.parseNodes(root);
var edges = TopologyHelper.parseEdges(root);
+ if (external) {
+ TopologyHelper.addExternalEndpoints(nodes, edges, root);
+ TopologyHelper.expandExternalEdges(nodes, edges);
+ }
// Enrich with metrics from route-structure console
if (metrics) {
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 67dcf8b59cd6..77c17f6e0e27 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
@@ -78,6 +78,11 @@ public class DiagramDevConsole extends AbstractDevConsole {
*/
public static final String FORMAT = "format";
+ /**
+ * Whether to include external systems (kafka, http, etc.) in topology
mode. Is default true.
+ */
+ public static final String EXTERNAL = "external";
+
public DiagramDevConsole() {
super("camel", "route-diagram", "Route Diagram", "Visual route
diagrams");
}
@@ -98,11 +103,13 @@ public class DiagramDevConsole extends AbstractDevConsole {
boolean refresh = "true".equalsIgnoreCase((String)
options.getOrDefault(AUTO_REFRESH, "true"));
String format = (String) options.getOrDefault(FORMAT, "html");
+ boolean external = "true".equalsIgnoreCase((String)
options.getOrDefault(EXTERNAL, "true"));
+
try {
RouteDiagramDumper dumper =
PluginHelper.getRouteDiagramDumper(getCamelContext());
boolean textFormat = isTextFormat(format);
if ("topology".equalsIgnoreCase(mode)) {
- sj.add(doCallTopologyText(dumper, theme, nodeWidth, metric,
fontSize, refresh));
+ sj.add(doCallTopologyText(dumper, theme, nodeWidth, metric,
fontSize, refresh, format, external));
} else if (isTextTheme(theme) || textFormat) {
boolean unicode = isUnicodeTheme(theme) ||
"unicode".equalsIgnoreCase(format);
String text = dumper.dumpRoutesAsAsciiArt(filter,
@@ -143,12 +150,13 @@ public class DiagramDevConsole extends AbstractDevConsole
{
.parseInt(options.getOrDefault(NODE_WIDTH, "" +
RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH).toString());
String nodeLabel = (String) options.getOrDefault(NODE_LABEL,
RouteDiagramDumper.NodeLabelMode.CODE.name());
boolean metric = "true".equalsIgnoreCase((String)
options.getOrDefault(METRIC, "true"));
+ boolean external = "true".equalsIgnoreCase((String)
options.getOrDefault(EXTERNAL, "true"));
JsonObject root = new JsonObject();
try {
RouteDiagramDumper dumper =
PluginHelper.getRouteDiagramDumper(getCamelContext());
if ("topology".equalsIgnoreCase(mode)) {
- return doCallTopologyJson(dumper, theme, nodeWidth, metric,
fontSize);
+ return doCallTopologyJson(dumper, theme, nodeWidth, metric,
fontSize, external);
} else if (isTextTheme(theme)) {
String text = dumper.dumpRoutesAsAsciiArt(filter,
RouteDiagramDumper.NodeLabelMode.valueOf(nodeLabel.toUpperCase()),
@@ -170,13 +178,15 @@ public class DiagramDevConsole extends AbstractDevConsole
{
private String doCallTopologyText(
RouteDiagramDumper dumper, String theme, int nodeWidth,
- boolean metric, int fontSize, boolean refresh)
+ boolean metric, int fontSize, boolean refresh, String format,
boolean external)
throws Exception {
- if (isTextTheme(theme)) {
- return dumper.dumpTopologyAsAsciiArt(nodeWidth,
isUnicodeTheme(theme));
- } else {
+ boolean textFormat = isTextFormat(format);
+ if (isTextTheme(theme) || textFormat) {
+ boolean unicode = isUnicodeTheme(theme) ||
"unicode".equalsIgnoreCase(format);
+ return dumper.dumpTopologyAsAsciiArt(nodeWidth, unicode, external);
+ } else if ("png".equalsIgnoreCase(format)) {
BufferedImage image = dumper.dumpTopologyAsImage(
- RouteDiagramDumper.Theme.valueOf(theme.toUpperCase()),
metric, nodeWidth, fontSize);
+ RouteDiagramDumper.Theme.valueOf(theme.toUpperCase()),
metric, nodeWidth, fontSize, external);
if (image == null) {
return "";
}
@@ -188,20 +198,22 @@ public class DiagramDevConsole extends AbstractDevConsole
{
html = "<head><meta http-equiv=\"refresh\"
content=\"5\"></head>\n" + html;
}
return "<html>\n" + html + "</html>\n";
+ } else {
+ return buildTopologyWebComponentHtml(metric, refresh, external);
}
}
private Map<String, Object> doCallTopologyJson(
RouteDiagramDumper dumper, String theme, int nodeWidth,
- boolean metric, int fontSize)
+ boolean metric, int fontSize, boolean external)
throws Exception {
JsonObject root = new JsonObject();
if (isTextTheme(theme)) {
- String text = dumper.dumpTopologyAsAsciiArt(nodeWidth,
isUnicodeTheme(theme));
+ String text = dumper.dumpTopologyAsAsciiArt(nodeWidth,
isUnicodeTheme(theme), external);
root.put("text", text);
} else {
BufferedImage image = dumper.dumpTopologyAsImage(
- RouteDiagramDumper.Theme.valueOf(theme.toUpperCase()),
metric, nodeWidth, fontSize);
+ RouteDiagramDumper.Theme.valueOf(theme.toUpperCase()),
metric, nodeWidth, fontSize, external);
if (image != null) {
String base64 = dumper.imageToBase64(image);
root.put("image", base64);
@@ -210,7 +222,25 @@ public class DiagramDevConsole extends AbstractDevConsole {
return root;
}
- private static final String WEB_COMPONENT_JS = loadWebComponentJs();
+ private static final String WEB_COMPONENT_JS =
loadWebComponentJs("camel-route-diagram.js");
+ private static final String TOPOLOGY_WEB_COMPONENT_JS =
loadWebComponentJs("camel-topology-diagram.js");
+
+ private static String buildTopologyWebComponentHtml(boolean metric,
boolean refresh, boolean external) {
+ String metricAttr = metric ? "" : " metric=\"false\"";
+ String refreshAttr = refresh ? " refresh=\"5000\"" : "";
+ String externalAttr = external ? "" : " external=\"false\"";
+ return "<html>\n"
+ + " <head>\n"
+ + " <meta charset=\"utf-8\">\n"
+ + " <script type=\"module\">\n" + TOPOLOGY_WEB_COMPONENT_JS
+ "\n </script>\n"
+ + " </head>\n"
+ + " <body>\n"
+ + String.format(
+ " <camel-topology-diagram
src=\"route-topology\"%s%s%s></camel-topology-diagram>%n",
+ metricAttr, refreshAttr, externalAttr)
+ + " </body>\n"
+ + "</html>\n";
+ }
private static String buildRouteWebComponentHtml(String filter, boolean
metric, boolean refresh) {
String f = filter == null ? "*" : filter;
@@ -231,9 +261,9 @@ public class DiagramDevConsole extends AbstractDevConsole {
+ "</html>\n";
}
- private static String loadWebComponentJs() {
+ private static String loadWebComponentJs(String filename) {
try (InputStream is = DiagramDevConsole.class.getResourceAsStream(
- "/META-INF/resources/camel/diagram/camel-route-diagram.js")) {
+ "/META-INF/resources/camel/diagram/" + filename)) {
return is != null ? new String(is.readAllBytes(),
StandardCharsets.UTF_8) : "";
} catch (IOException e) {
return "";
diff --git
a/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/camel-topology-diagram.js
b/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/camel-topology-diagram.js
new file mode 100644
index 000000000000..393e6443a6b4
--- /dev/null
+++
b/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/camel-topology-diagram.js
@@ -0,0 +1,590 @@
+/*
+ * 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.
+ */
+
+// ─── Layout engine (ported from TopologyLayoutEngine.java) ──────────────────
+
+const NODE_W = 180;
+const NODE_H = 40;
+const V_GAP = 50;
+const H_GAP = 30;
+const BAND_GAP = 80;
+const PADDING = 30;
+const ARROW_SIZE = 6;
+
+// ─── External endpoint helpers (ported from TopologyHelper.java) ────────────
+
+function addExternalEndpoints(nodes, edges, data) {
+ const ext = data.externalEndpoints;
+ if (!ext) return;
+ for (const ep of ext) {
+ const colonIdx = ep.uri.indexOf(':');
+ const desc = colonIdx > 0 ? ep.uri.substring(colonIdx + 1) : ep.uri;
+ nodes.push({
+ routeId: ep.id,
+ description: desc,
+ from: ep.uri,
+ fromScheme: ep.scheme,
+ nodeType: ep.direction === 'in' ? 'external-in' : 'external-out',
+ exchangesTotal: ep.exchangesTotal ?? 0,
+ exchangesFailed: ep.exchangesFailed ?? 0,
+ });
+ const edge = { endpoint: ep.uri, connectionType: 'external' };
+ if (ep.direction === 'in') {
+ edge.fromRouteId = ep.id;
+ edge.toRouteId = ep.routeId;
+ } else {
+ edge.fromRouteId = ep.routeId;
+ edge.toRouteId = ep.id;
+ }
+ edges.push(edge);
+ }
+}
+
+function expandExternalEdges(nodes, edges) {
+ const routeIds = new Set(
+ nodes.filter(n => !n.nodeType ||
!n.nodeType.startsWith('external')).map(n => n.routeId),
+ );
+ const byEndpoint = new Map();
+ for (const e of edges) {
+ if (e.connectionType === 'external' && routeIds.has(e.fromRouteId) &&
routeIds.has(e.toRouteId)) {
+ if (!byEndpoint.has(e.endpoint)) byEndpoint.set(e.endpoint, []);
+ byEndpoint.get(e.endpoint).push(e);
+ }
+ }
+ if (byEndpoint.size === 0) return;
+
+ let idx = 0;
+ for (const [uri, group] of byEndpoint) {
+ const colonIdx = uri.indexOf(':');
+ const extNode = {
+ routeId: 'ext-' + idx++,
+ from: uri,
+ nodeType: 'external',
+ description: colonIdx > 0 ? uri.substring(colonIdx + 1) : uri,
+ fromScheme: colonIdx > 0 ? uri.substring(0, colonIdx) : undefined,
+ exchangesTotal: 0,
+ exchangesFailed: 0,
+ };
+ nodes.push(extNode);
+ for (const orig of group) {
+ const origIdx = edges.indexOf(orig);
+ if (origIdx >= 0) edges.splice(origIdx, 1);
+ edges.push({ fromRouteId: orig.fromRouteId, toRouteId:
extNode.routeId, endpoint: uri, connectionType: 'external' });
+ edges.push({ fromRouteId: extNode.routeId, toRouteId:
orig.toRouteId, endpoint: uri, connectionType: 'external' });
+ }
+ }
+}
+
+// ─── Sugiyama layout ────────────────────────────────────────────────────────
+
+function assignRouteLayers(routeNodes, successors, predecessors) {
+ const layers = new Map();
+ const routeIds = new Set(routeNodes.map(n => n.routeId));
+ const assigned = new Set();
+
+ for (const n of routeNodes) {
+ const preds = predecessors.get(n.routeId) ?? [];
+ const hasRoutePred = preds.some(p => routeIds.has(p));
+ if (n.nodeType === 'trigger' || !hasRoutePred) {
+ layers.set(n.routeId, 0);
+ assigned.add(n.routeId);
+ }
+ }
+ if (assigned.size === 0 && routeNodes.length > 0) {
+ layers.set(routeNodes[0].routeId, 0);
+ assigned.add(routeNodes[0].routeId);
+ }
+
+ let changed = true;
+ while (changed) {
+ changed = false;
+ for (const n of routeNodes) {
+ if (!assigned.has(n.routeId)) continue;
+ for (const succ of (successors.get(n.routeId) ?? [])) {
+ if (succ === n.routeId || !routeIds.has(succ)) continue;
+ const newLayer = layers.get(n.routeId) + 1;
+ if (!assigned.has(succ) || layers.get(succ) < newLayer) {
+ layers.set(succ, newLayer);
+ assigned.add(succ);
+ changed = true;
+ }
+ }
+ }
+ }
+
+ for (const n of routeNodes) {
+ if (!layers.has(n.routeId)) layers.set(n.routeId, 0);
+ }
+ return layers;
+}
+
+function minimizeCrossings(layerGroups, successors, predecessors) {
+ function orderByBarycenter(layer, refLayer, neighbors) {
+ const refPos = new Map();
+ refLayer.forEach((id, i) => refPos.set(id, i));
+ const bary = new Map();
+ for (const nodeId of layer) {
+ const connected = neighbors.get(nodeId) ?? [];
+ if (connected.length === 0) { bary.set(nodeId, Infinity);
continue; }
+ let sum = 0, count = 0;
+ for (const nb of connected) {
+ const pos = refPos.get(nb);
+ if (pos !== undefined) { sum += pos; count++; }
+ }
+ bary.set(nodeId, count > 0 ? sum / count : Infinity);
+ }
+ layer.sort((a, b) => bary.get(a) - bary.get(b));
+ }
+
+ for (let pass = 0; pass < 4; pass++) {
+ for (let i = 1; i < layerGroups.length; i++) {
+ orderByBarycenter(layerGroups[i], layerGroups[i - 1],
predecessors);
+ }
+ for (let i = layerGroups.length - 2; i >= 0; i--) {
+ orderByBarycenter(layerGroups[i], layerGroups[i + 1], successors);
+ }
+ }
+}
+
+function layoutTopology(nodes, edges) {
+ if (!nodes.length) return { layoutNodes: [], layoutEdges: [], totalWidth:
0, totalHeight: 0 };
+
+ const extInNodes = [], extOutNodes = [], routeNodes = [];
+ for (const n of nodes) {
+ if (n.nodeType === 'external-in') extInNodes.push(n);
+ else if (n.nodeType === 'external-out') extOutNodes.push(n);
+ else routeNodes.push(n);
+ }
+
+ const nodeMap = new Map();
+ for (const n of nodes) nodeMap.set(n.routeId, n);
+
+ const successors = new Map(), predecessors = new Map();
+ for (const n of nodes) {
+ successors.set(n.routeId, []);
+ predecessors.set(n.routeId, []);
+ }
+ for (const e of edges) {
+ if (nodeMap.has(e.fromRouteId) && nodeMap.has(e.toRouteId)) {
+ successors.get(e.fromRouteId).push(e.toRouteId);
+ predecessors.get(e.toRouteId).push(e.fromRouteId);
+ }
+ }
+
+ const hasExtIn = extInNodes.length > 0;
+ const hasExtOut = extOutNodes.length > 0;
+ const layers = assignRouteLayers(routeNodes, successors, predecessors);
+
+ if (hasExtIn) {
+ for (const [key, val] of layers) layers.set(key, val + 1);
+ }
+ for (const n of extInNodes) layers.set(n.routeId, 0);
+
+ let maxRouteLayer = 0;
+ for (const [key, val] of layers) {
+ if (!extOutNodes.some(n => n.routeId === key)) maxRouteLayer =
Math.max(maxRouteLayer, val);
+ }
+ const outLayer = maxRouteLayer + 1;
+ for (const n of extOutNodes) layers.set(n.routeId, outLayer);
+
+ const maxLayer = Math.max(...layers.values(), 0);
+ const layerGroups = Array.from({ length: maxLayer + 1 }, () => []);
+ for (const n of nodes) {
+ const l = layers.get(n.routeId) ?? 0;
+ layerGroups[l].push(n.routeId);
+ }
+
+ minimizeCrossings(layerGroups, successors, predecessors);
+
+ const extInLayer = hasExtIn ? 0 : -1;
+ const extOutLayer = hasExtOut ? outLayer : -1;
+
+ // Assign coordinates
+ let maxLayerWidth = 0;
+ for (const layer of layerGroups) {
+ const w = layer.length * (NODE_W + H_GAP) - H_GAP;
+ maxLayerWidth = Math.max(maxLayerWidth, w);
+ }
+
+ const layoutNodes = new Map();
+ let cumY = PADDING;
+ for (let li = 0; li < layerGroups.length; li++) {
+ const layer = layerGroups[li];
+ const lw = layer.length * (NODE_W + H_GAP) - H_GAP;
+ const startX = PADDING + (maxLayerWidth - lw) / 2;
+ for (let i = 0; i < layer.length; i++) {
+ const rid = layer[i];
+ const info = nodeMap.get(rid);
+ layoutNodes.set(rid, {
+ routeId: rid,
+ description: info.description,
+ from: info.from,
+ fromScheme: info.fromScheme,
+ nodeType: info.nodeType,
+ connectionType: info.connectionType,
+ x: startX + i * (NODE_W + H_GAP),
+ y: cumY,
+ width: NODE_W,
+ height: NODE_H,
+ layer: li,
+ exchangesTotal: info.exchangesTotal ?? 0,
+ exchangesFailed: info.exchangesFailed ?? 0,
+ });
+ }
+ let gap = V_GAP;
+ if (li === extInLayer || (extOutLayer >= 0 && li === extOutLayer - 1))
gap = BAND_GAP;
+ cumY += NODE_H + gap;
+ }
+
+ // Build layout edges
+ const layoutEdges = [];
+ for (const e of edges) {
+ const from = layoutNodes.get(e.fromRouteId);
+ const to = layoutNodes.get(e.toRouteId);
+ if (from && to) {
+ const backEdge = (layers.get(e.fromRouteId) ?? 0) >=
(layers.get(e.toRouteId) ?? 0)
+ && e.fromRouteId !== e.toRouteId;
+ const selfLoop = e.fromRouteId === e.toRouteId;
+ layoutEdges.push({ from, to, endpoint: e.endpoint, connectionType:
e.connectionType, backEdge, selfLoop });
+ }
+ }
+
+ const allNodes = [...layoutNodes.values()];
+ const totalWidth = allNodes.reduce((m, n) => Math.max(m, n.x + NODE_W), 0)
+ PADDING;
+ const totalHeight = allNodes.reduce((m, n) => Math.max(m, n.y + NODE_H),
0) + PADDING;
+ return { layoutNodes: allNodes, layoutEdges, totalWidth, totalHeight };
+}
+
+// ─── Web component ──────────────────────────────────────────────────────────
+
+function isExternal(nodeType) {
+ return nodeType === 'external-in' || nodeType === 'external-out' ||
nodeType === 'external';
+}
+
+function truncate(text, maxLen = 28) {
+ if (!text) return '';
+ const clean = text.replace(/^\.+/, '');
+ return clean.length > maxLen ? clean.slice(0, maxLen - 1) + '…' : clean;
+}
+
+function esc(s) {
+ return String(s ?? '')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"');
+}
+
+const COMPONENT_STYLE = `
+ :host {
+ display: block;
+ width: fit-content;
+ min-width: 100%;
+ font-family: var(--ctd-font, system-ui, sans-serif);
+ font-size: var(--ctd-font-size, 12px);
+ color: var(--ctd-fg, #1e293b);
+ }
+ @media (prefers-color-scheme: dark) {
+ :host { color: var(--ctd-fg, #e2e8f0); }
+ }
+ .wrap {
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ background: var(--ctd-bg, transparent);
+ padding: 12px;
+ }
+ @media (prefers-color-scheme: dark) {
+ .wrap { background: var(--ctd-bg, #0f172a); }
+ }
+ .error { color: #ef4444; padding: 8px; }
+ .loading { opacity: .6; padding: 8px; }
+ svg { display: block; overflow: visible; }
+`;
+
+// SVG icon paths from Lucide (https://lucide.dev) — ISC License
+// Copyright (c) Lucide Contributors 2022; portions (c) Cole Bemis 2013-2022
(Feather, MIT)
+const ICONS = {
+ workflow: '<rect width="8" height="8" x="3" y="3" rx="2"/><path d="M7
11v4a2 2 0 0 0 2 2h4"/><rect width="8" height="8" x="13" y="13" rx="2"/>',
+ 'log-in': '<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline
points="10 17 15 12 10 7"/><line x1="15" x2="3" y1="12" y2="12"/>',
+ zap: '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1
.86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0
1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>',
+ cloud: '<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0
9Z"/>',
+};
+
+function iconFor(nodeType) {
+ if (isExternal(nodeType)) return ICONS.cloud;
+ if (nodeType === 'trigger') return ICONS.zap;
+ return ICONS.workflow;
+}
+
+function nodeColor(nodeType) {
+ if (isExternal(nodeType)) return 'var(--ctd-color-external, #64748b)';
+ if (nodeType === 'trigger') return 'var(--ctd-color-trigger, #f59e0b)';
+ return 'var(--ctd-color-route, #6366f1)';
+}
+
+/**
+ * A web component that renders Apache Camel topology diagrams as interactive
SVG.
+ *
+ * Attributes:
+ * src - URL of the route-topology dev console endpoint (required)
+ * refresh - polling interval in ms; 0 = disabled (default: 0)
+ * metric - show live metrics (default: true)
+ * external - include external endpoint nodes (default: true)
+ * interlink - show intermediary nodes for routes connected via shared
externals (default: true)
+ *
+ * CSS custom properties (all optional):
+ * --ctd-bg, --ctd-fg, --ctd-edge, --ctd-font, --ctd-font-size
+ * --ctd-color-route, --ctd-color-trigger, --ctd-color-external
+ *
+ * @since 4.21
+ */
+class CamelTopologyDiagram extends HTMLElement {
+ static observedAttributes = ['src', 'refresh', 'metric', 'external',
'interlink'];
+
+ #src = '';
+ #refresh = 0;
+ #metric = true;
+ #external = true;
+ #interlink = true;
+ #timer = null;
+ #controller = null;
+ #data = null;
+ #error = null;
+
+ constructor() {
+ super();
+ this.attachShadow({ mode: 'open' });
+ }
+
+ //noinspection JSUnusedGlobalSymbols
+ connectedCallback() {
+ this.#scheduleRefresh();
+ this.#render();
+ this.#doFetch();
+ }
+
+ //noinspection JSUnusedGlobalSymbols
+ disconnectedCallback() {
+ clearInterval(this.#timer);
+ this.#timer = null;
+ this.#controller?.abort();
+ }
+
+ //noinspection JSUnusedGlobalSymbols
+ attributeChangedCallback(name, oldValue, newValue) {
+ if (oldValue === newValue) return;
+ switch (name) {
+ case 'src':
+ this.#src = newValue ?? '';
+ if (this.isConnected) this.#doFetch();
+ break;
+ case 'metric':
+ this.#metric = newValue !== 'false';
+ if (this.isConnected) this.#doFetch();
+ break;
+ case 'external':
+ this.#external = newValue !== 'false';
+ if (this.isConnected) this.#render();
+ break;
+ case 'interlink':
+ this.#interlink = newValue !== 'false';
+ if (this.isConnected) this.#render();
+ break;
+ case 'refresh':
+ this.#refresh = Number(newValue) || 0;
+ if (this.isConnected) this.#scheduleRefresh();
+ break;
+ }
+ }
+
+ #scheduleRefresh() {
+ clearInterval(this.#timer);
+ this.#timer = null;
+ if (this.#refresh > 0) {
+ this.#timer = setInterval(() => this.#doFetch(), this.#refresh);
+ }
+ }
+
+ async #doFetch() {
+ const src = this.#src?.trim();
+ if (!src) return;
+ this.#controller?.abort();
+ this.#controller = new AbortController();
+ try {
+ const url = new URL(src, location.href);
+ url.searchParams.set('external', 'true');
+ url.searchParams.set('metric', String(this.#metric));
+ const res = await fetch(url, {
+ signal: this.#controller.signal,
+ headers: { 'Accept': 'application/json' },
+ });
+ if (!res.ok) {
+ this.#error = `HTTP ${res.status} ${res.statusText}`;
+ this.#render();
+ return;
+ }
+ let data = await res.json();
+ if (data && !Array.isArray(data.nodes) && data['route-topology']) {
+ data = data['route-topology'];
+ }
+ if (!Array.isArray(data?.nodes)) {
+ this.#error = 'Unexpected response: missing nodes array';
+ this.#render();
+ return;
+ }
+ this.#data = data;
+ this.#error = null;
+ this.#render();
+ } catch (e) {
+ if (e.name !== 'AbortError') {
+ this.#error = e.message;
+ this.#render();
+ }
+ }
+ }
+
+ #render() {
+ this.shadowRoot.innerHTML = this.#buildHTML();
+ }
+
+ #buildHTML() {
+ const style = `<style>${COMPONENT_STYLE}</style>`;
+ if (this.#error) return `${style}<div class="wrap"><p class="error">⚠
${esc(this.#error)}</p></div>`;
+ if (!this.#data) return `${style}<div class="wrap"><p
class="loading">Loading topology…</p></div>`;
+ return style + `<div class="wrap">${this.#topologyHTML()}</div>`;
+ }
+
+ #topologyHTML() {
+ const data = this.#data;
+
+ // Parse nodes and edges
+ const nodes = (data.nodes ?? []).map(n => ({
+ routeId: n.routeId,
+ description: n.description ?? null,
+ from: n.from,
+ fromScheme: n.fromScheme,
+ nodeType: n.nodeType ?? 'route',
+ exchangesTotal: n.exchangesTotal ?? 0,
+ exchangesFailed: n.exchangesFailed ?? 0,
+ }));
+ const edges = (data.edges ?? []).map(e => ({
+ fromRouteId: e.fromRouteId,
+ toRouteId: e.toRouteId,
+ endpoint: e.endpoint,
+ connectionType: e.connectionType ?? 'internal',
+ }));
+
+ if (this.#external) {
+ addExternalEndpoints(nodes, edges, data);
+ if (this.#interlink) {
+ expandExternalEdges(nodes, edges);
+ }
+ }
+
+ const { layoutNodes, layoutEdges, totalWidth, totalHeight } =
layoutTopology(nodes, edges);
+ if (!layoutNodes.length) return '<p class="loading">No routes</p>';
+
+ const edgeSvg = layoutEdges.map(e => this.#edgeHTML(e)).join('');
+ const nodeSvg = layoutNodes.map(n => this.#nodeHTML(n)).join('');
+
+ return `<svg width="${totalWidth}" height="${totalHeight}" viewBox="0
0 ${totalWidth} ${totalHeight}"
+ aria-label="Route topology diagram">
+ ${edgeSvg}${nodeSvg}
+ </svg>`;
+ }
+
+ #edgeHTML(edge) {
+ if (edge.selfLoop) return '';
+
+ const ext = isExternal(edge.from.nodeType) ||
isExternal(edge.to.nodeType);
+ const dashAttr = ext ? ' stroke-dasharray="6 4"' : '';
+ const fromCx = edge.from.x + NODE_W / 2;
+ const fromBy = edge.from.y + NODE_H;
+ const toCx = edge.to.x + NODE_W / 2;
+ const toTy = edge.to.y;
+ const endY = toTy - ARROW_SIZE / 2;
+
+ const path = fromCx === toCx
+ ? `M${fromCx},${fromBy} L${toCx},${endY}`
+ : `M${fromCx},${fromBy} L${fromCx},${(fromBy + toTy) / 2}
L${toCx},${(fromBy + toTy) / 2} L${toCx},${endY}`;
+
+ return `
+ <path d="${path}" fill="none"
+ stroke="var(--ctd-edge, #94a3b8)" stroke-width="1.5"
+ stroke-linecap="round" stroke-linejoin="round"${dashAttr}/>
+ <polygon
+ points="${toCx - ARROW_SIZE},${toTy - ARROW_SIZE} ${toCx},${toTy}
${toCx + ARROW_SIZE},${toTy - ARROW_SIZE}"
+ fill="var(--ctd-edge, #94a3b8)"/>`;
+ }
+
+ #nodeHTML(node) {
+ const ext = isExternal(node.nodeType);
+ const fill = nodeColor(node.nodeType);
+ const dashAttr = ext ? ' stroke-dasharray="4 3"' : '';
+
+ const label = ext ? truncate(node.from, 28) :
truncate(node.description ?? node.routeId, 28);
+ const subLabel = ext ? null : `(${truncate(node.from, 24)})`;
+
+ const stat = this.#metric && node.exchangesTotal > 0
+ ? { total: node.exchangesTotal, failed: node.exchangesFailed } :
null;
+
+ const textX = node.x + 30;
+ const baseY = node.y + NODE_H / 2;
+
+ let line1Y, line2Y;
+ if (ext || !subLabel) {
+ line1Y = baseY + 4;
+ line2Y = null;
+ } else {
+ line1Y = baseY - 2;
+ line2Y = baseY + 12;
+ }
+
+ return `
+ <g role="img" aria-label="${esc(node.routeId)}: ${esc(label)}">
+ <rect x="${node.x}" y="${node.y}" width="${NODE_W}" height="${NODE_H}"
+ rx="6" ry="6" fill="var(--ctd-bg, #ffffff)"/>
+ <rect x="${node.x}" y="${node.y}" width="${NODE_W}" height="${NODE_H}"
+ rx="6" ry="6"
+ fill="${fill}" fill-opacity="${ext ? '0.08' : '0.15'}"
+ stroke="${fill}" stroke-width="1.5"${dashAttr}/>
+ <text x="${textX}" y="${line1Y}"
+ text-anchor="start" fill="currentColor" font-size="11">
+ ${esc(label)}
+ </text>
+ ${line2Y != null ? `
+ <text x="${textX}" y="${line2Y}"
+ text-anchor="start" fill="currentColor" font-size="9"
opacity="0.7">
+ ${esc(subLabel)}
+ </text>` : ''}
+ ${stat ? `
+ <text x="${node.x + NODE_W - 8}" y="${node.y + 12}"
+ text-anchor="end" font-size="9">
+ <tspan fill="#22c55e">${stat.total -
stat.failed}</tspan>${stat.failed > 0
+ ? `<tspan dx="4" fill="#ef4444">${stat.failed}</tspan>` : ''}
+ </text>` : ''}
+ <g transform="translate(${node.x + 10},${node.y + (NODE_H - 14) / 2})
scale(0.5833)"
+ fill="none" stroke="${fill}" stroke-width="2.4"
+ stroke-linecap="round" stroke-linejoin="round"
pointer-events="none">
+ ${iconFor(node.nodeType)}
+ </g>
+ </g>`;
+ }
+}
+
+customElements.define('camel-topology-diagram', CamelTopologyDiagram);
diff --git
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/DiagramDevConsoleTest.java
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/DiagramDevConsoleTest.java
index 3dd56acbfb5c..34107a931931 100644
---
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/DiagramDevConsoleTest.java
+++
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/DiagramDevConsoleTest.java
@@ -89,6 +89,43 @@ class DiagramDevConsoleTest extends CamelTestSupport {
assertThat(text).doesNotContain("<camel-route-diagram");
}
+ @Test
+ void testTopologyHtmlOutput() {
+ System.setProperty("java.awt.headless", "true");
+ DevConsole console = resolveConsole();
+ String text = (String) console.call(DevConsole.MediaType.TEXT,
Map.of(DiagramDevConsole.MODE, "topology"));
+ assertThat(text).isNotNull();
+ assertThat(text).contains("<html>");
+ assertThat(text).contains("<meta charset=\"utf-8\">");
+ assertThat(text).contains("<camel-topology-diagram");
+ assertThat(text).contains("src=\"route-topology\"");
+ assertThat(text).contains("customElements.define");
+ assertThat(text).doesNotContain("data:image/png;base64,");
+ }
+
+ @Test
+ void testTopologyAsciiOutput() {
+ System.setProperty("java.awt.headless", "true");
+ DevConsole console = resolveConsole();
+ String text = (String) console.call(DevConsole.MediaType.TEXT,
+ Map.of(DiagramDevConsole.MODE, "topology",
DiagramDevConsole.THEME, "ascii"));
+ assertThat(text).isNotNull();
+ assertThat(text).doesNotContain("<html>");
+ assertThat(text).contains("myRoute");
+ }
+
+ @Test
+ void testTopologyPngOutput() {
+ System.setProperty("java.awt.headless", "true");
+ DevConsole console = resolveConsole();
+ String text = (String) console.call(DevConsole.MediaType.TEXT,
+ Map.of(DiagramDevConsole.MODE, "topology",
DiagramDevConsole.FORMAT, "png"));
+ assertThat(text).isNotNull();
+ assertThat(text).contains("<html>");
+ assertThat(text).contains("data:image/png;base64,");
+ assertThat(text).doesNotContain("<camel-topology-diagram");
+ }
+
@Test
void testJsonOutput() {
System.setProperty("java.awt.headless", "true");
diff --git
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/WebComponentBundleTest.java
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/WebComponentBundleTest.java
index e2c244f5c369..3af5a6cb81d1 100644
---
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/WebComponentBundleTest.java
+++
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/WebComponentBundleTest.java
@@ -76,6 +76,49 @@ class WebComponentBundleTest {
}
}
+ @Test
+ void topologyBundledJsExistsInClasspath() {
+ URL url = getClass().getClassLoader()
+
.getResource("META-INF/resources/camel/diagram/camel-topology-diagram.js");
+ assertThat(url).as("camel-topology-diagram.js must be
bundled").isNotNull();
+ }
+
+ @Test
+ void topologyBundledJsIsNonEmpty() throws IOException {
+ try (InputStream is = getClass().getClassLoader()
+
.getResourceAsStream("META-INF/resources/camel/diagram/camel-topology-diagram.js"))
{
+ assertThat(is).isNotNull();
+ assertThat(is.readAllBytes().length).isGreaterThan(1000);
+ }
+ }
+
+ @Test
+ void topologyBundledJsContainsCustomElementRegistration() throws
IOException {
+ try (InputStream is = getClass().getClassLoader()
+
.getResourceAsStream("META-INF/resources/camel/diagram/camel-topology-diagram.js"))
{
+ assertThat(is).isNotNull();
+ String content = new String(is.readAllBytes(),
StandardCharsets.UTF_8);
+ assertThat(content)
+ .as("bundle must register the camel-topology-diagram
custom element")
+ .contains("customElements.define")
+ .contains("camel-topology-diagram");
+ }
+ }
+
+ @Test
+ void topologyBundledJsContainsLayoutEngine() throws IOException {
+ try (InputStream is = getClass().getClassLoader()
+
.getResourceAsStream("META-INF/resources/camel/diagram/camel-topology-diagram.js"))
{
+ assertThat(is).isNotNull();
+ String content = new String(is.readAllBytes(),
StandardCharsets.UTF_8);
+ assertThat(content)
+ .as("topology bundle must contain Sugiyama layout engine
and external endpoint helpers")
+ .contains("layoutTopology")
+ .contains("addExternalEndpoints")
+ .contains("expandExternalEdges");
+ }
+ }
+
@Test
void thirdPartyNoticesMentionsLucide() throws IOException {
try (InputStream is = getClass().getClassLoader()
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 d72b06614cc7..6d0bff14af83 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
@@ -145,6 +145,19 @@ public interface RouteDiagramDumper {
* @since 4.21
*/
default String dumpTopologyAsAsciiArt(int nodeWidth, boolean unicode) {
+ return dumpTopologyAsAsciiArt(nodeWidth, unicode, false);
+ }
+
+ /**
+ * 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
+ * @param external whether to include external systems (kafka, http, etc.)
+ *
+ * @since 4.21
+ */
+ default String dumpTopologyAsAsciiArt(int nodeWidth, boolean unicode,
boolean external) {
throw new UnsupportedOperationException();
}
@@ -159,6 +172,21 @@ public interface RouteDiagramDumper {
* @since 4.21
*/
default BufferedImage dumpTopologyAsImage(Theme theme, boolean metrics,
int nodeWidth, int fontSize) {
+ return dumpTopologyAsImage(theme, metrics, nodeWidth, fontSize, false);
+ }
+
+ /**
+ * 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
+ * @param external whether to include external systems (kafka, http, etc.)
+ *
+ * @since 4.21
+ */
+ default BufferedImage dumpTopologyAsImage(Theme theme, boolean metrics,
int nodeWidth, int fontSize, boolean external) {
throw new UnsupportedOperationException();
}
diff --git a/docs/user-manual/modules/ROOT/pages/route-diagram.adoc
b/docs/user-manual/modules/ROOT/pages/route-diagram.adoc
index a2a3a849219a..72a7261b8317 100644
--- a/docs/user-manual/modules/ROOT/pages/route-diagram.adoc
+++ b/docs/user-manual/modules/ROOT/pages/route-diagram.adoc
@@ -39,6 +39,19 @@ TIP: See more options with `camel cmd route-diagram --help`.
And if you run Camel CLI with `--console` then the developer console also
comes with this functionality,
by opening the link: http://localhost:8080/q/dev/route-diagram
+To see a topology diagram (how routes connect to each other) in the browser,
use:
+
+ http://localhost:8080/q/dev/route-diagram?mode=topology
+
+The topology view supports the following query parameters:
+
+- `mode=topology` — switch from route diagram (default) to topology diagram
+- `format=html` — interactive SVG web component (default)
+- `format=png` — static PNG image
+- `format=text` — ASCII art, `format=unicode` — Unicode box-drawing characters
+- `external=true|false` — include/exclude external systems such as Kafka,
HTTP, etc. (default: `true`)
+- `metric=true|false` — show live exchange counters (default: `true`)
+
== Generating Route Diagrams with Camel Main
@@ -151,7 +164,8 @@ camel cmd route-topology
See xref:jbang-commands/camel-jbang-cmd-route-topology.adoc[] for more details.
-The developer console is also available at:
`http://localhost:8080/q/dev/route-topology`
+The developer console is also available at:
`http://localhost:8080/q/dev/route-topology` (JSON/text data),
+or as a visual HTML diagram at:
`http://localhost:8080/q/dev/route-diagram?mode=topology`
=== Generating Topology Diagrams during `mvn test`