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, '&amp;')
+        .replace(/</g, '&lt;')
+        .replace(/>/g, '&gt;')
+        .replace(/"/g, '&quot;');
+}
+
+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`
 


Reply via email to