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 18:35:11 UTC

(camel) branch main updated: CAMEL-23656: Add route topology service and TUI diagram tab (#23680)

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 06233633793d CAMEL-23656: Add route topology service and TUI diagram tab (#23680)
06233633793d is described below

commit 06233633793d9391c48de6f66504b47444e07397
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Mon Jun 1 20:35:05 2026 +0200

    CAMEL-23656: Add route topology service and TUI diagram tab (#23680)
    
    * CAMEL-23648: camel-jbang - TUI dedicated Diagram tab with topology view
    
    Add a new Diagram tab (key 3) that shows inter-route topology by default.
    The topology auto-loads as text diagram when the tab is selected, with
    metrics enabled. Shift Routes to key 4 and move Metrics into the More
    dropdown. Fix consistent box heights in topology renderer by reserving
    2 lines for from text and always adding a metrics line.
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    * CAMEL-23648: camel-jbang - TUI use text-only diagrams, remove PNG image mode
    
    Simplify both Diagram and Routes tabs to text-only rendering. This
    removes flickering from PNG re-rendering during metrics refresh and
    better fits the TUI paradigm.
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    * CAMEL-23648: camel-jbang - TUI add description toggle and footer fixes
    
    Add description toggle (n key) for both topology and route diagrams.
    Standardize footer hint wording to top/end and fix spacing between hints.
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    * CAMEL-23648: camel-jbang - TUI use text-only diagram in Inspect tab
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 <no...@anthropic.com>
---
 .../camel/diagram/TopologyAsciiRenderer.java       |  57 +++--
 .../apache/camel/diagram/TopologyLayoutEngine.java |   4 +-
 .../dsl/jbang/core/commands/tui/CamelMonitor.java  |  83 ++++---
 .../jbang/core/commands/tui/DiagramSupport.java    | 129 ++++++++++-
 .../dsl/jbang/core/commands/tui/DiagramTab.java    | 256 +++++++++++++++++++++
 .../dsl/jbang/core/commands/tui/HistoryTab.java    |   9 +-
 .../dsl/jbang/core/commands/tui/RoutesTab.java     |  22 +-
 .../dsl/jbang/core/commands/tui/StartupTab.java    |   2 +-
 8 files changed, 480 insertions(+), 82 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 cccda5f20544..25c6f23dcd4b 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
@@ -52,14 +52,14 @@ public class TopologyAsciiRenderer {
     private final boolean showDescription;
     private final List<CounterPos> counterPositions = new ArrayList<>();
 
-    enum CounterType {
+    public enum CounterType {
         OK,
         FAIL,
         TRIGGER,
         EXTERNAL
     }
 
-    record CounterPos(int row, int col, int length, CounterType type) {
+    public record CounterPos(int row, int col, int length, CounterType type) {
     }
 
     public TopologyAsciiRenderer(int nodeWidth, boolean unicode) {
@@ -78,7 +78,16 @@ public class TopologyAsciiRenderer {
         return boxWidth;
     }
 
+    public List<CounterPos> getCounterPositions() {
+        return counterPositions;
+    }
+
     public String renderDiagram(TopologyLayoutResult result) {
+        String plain = renderDiagramPlain(result);
+        return applyAnsiColors(plain);
+    }
+
+    public String renderDiagramPlain(TopologyLayoutResult result) {
         counterPositions.clear();
 
         int gridWidth = toCol(result.totalWidth) + boxWidth + 4;
@@ -110,8 +119,7 @@ public class TopologyAsciiRenderer {
             drawNode(grid, node);
         }
 
-        String plain = gridToString(grid);
-        return applyAnsiColors(plain);
+        return gridToString(grid);
     }
 
     private void drawNode(char[][] grid, TopologyLayoutNode node) {
@@ -129,22 +137,31 @@ public class TopologyAsciiRenderer {
         lines.addAll(wrapText(line1, boxWidth - 4));
         if (!showDescription) {
             String line2 = "(" + node.from + ")";
-            lines.addAll(wrapText(line2, boxWidth - 4));
+            List<String> fromLines = wrapText(line2, boxWidth - 4);
+            lines.addAll(fromLines);
+            // Always reserve 2 lines for from so all boxes have the same height
+            if (fromLines.size() < 2) {
+                lines.add("");
+            }
         }
 
-        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("/");
+        if (metrics) {
+            if (node.exchangesTotal > 0 || node.exchangesFailed > 0) {
+                long ok = node.exchangesTotal - node.exchangesFailed;
+                StringBuilder sb = new StringBuilder();
+                if (ok > 0) {
+                    sb.append(ok);
                 }
-                sb.append(node.exchangesFailed).append("!");
+                if (node.exchangesFailed > 0) {
+                    if (!sb.isEmpty()) {
+                        sb.append("/");
+                    }
+                    sb.append(node.exchangesFailed).append("!");
+                }
+                lines.add(sb.toString());
+            } else {
+                lines.add("");
             }
-            lines.add(sb.toString());
         }
 
         // Trim to MAX_WRAP_LINES
@@ -191,10 +208,6 @@ public class TopologyAsciiRenderer {
             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) {
@@ -278,8 +291,8 @@ public class TopologyAsciiRenderer {
     }
 
     private int boxHeight(TopologyLayoutNode node) {
-        int lines = 2; // routeId + from
-        if (metrics && node.exchangesTotal > 0) {
+        int lines = 3; // routeId + from (2 lines reserved)
+        if (metrics) {
             lines++;
         }
         return 2 + Math.min(lines, MAX_WRAP_LINES + 1);
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 4fba5a9aa22d..b00026da41f0 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
@@ -34,9 +34,9 @@ public class TopologyLayoutEngine {
     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;
+    public static final int DEFAULT_NODE_WIDTH = 180;
     static final int DEFAULT_NODE_HEIGHT = 40;
-    static final int DEFAULT_FONT_SIZE = 12;
+    public static final int DEFAULT_FONT_SIZE = 12;
 
     private final int nodeWidth;
     private final int nodeHeight;
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index 5db7e2fa6d69..e22a456156f0 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -107,13 +107,13 @@ public class CamelMonitor extends CamelCommand {
     // Tab indices
     private static final int TAB_OVERVIEW = 0;
     private static final int TAB_LOG = 1;
-    private static final int TAB_ROUTES = 2;
-    private static final int TAB_ENDPOINTS = 3;
-    private static final int TAB_HTTP = 4;
-    private static final int TAB_HEALTH = 5;
-    private static final int TAB_HISTORY = 6;
-    private static final int TAB_ERRORS = 7;
-    private static final int TAB_METRICS = 8;
+    private static final int TAB_DIAGRAM = 2;
+    private static final int TAB_ROUTES = 3;
+    private static final int TAB_ENDPOINTS = 4;
+    private static final int TAB_HTTP = 5;
+    private static final int TAB_HEALTH = 6;
+    private static final int TAB_HISTORY = 7;
+    private static final int TAB_ERRORS = 8;
     private static final int TAB_MORE = 9;
 
     @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1")
@@ -250,6 +250,7 @@ public class CamelMonitor extends CamelCommand {
 
     private MonitorContext ctx;
     private LogTab logTab;
+    private DiagramTab diagramTab;
     private RoutesTab routesTab;
     private ConsumersTab consumersTab;
     private EndpointsTab endpointsTab;
@@ -314,6 +315,7 @@ public class CamelMonitor extends CamelCommand {
         actionsPopup.setContext(ctx);
         actionsPopup.setResetStatsAction(this::resetStats);
         logTab = new LogTab(ctx);
+        diagramTab = new DiagramTab(ctx);
         routesTab = new RoutesTab(ctx);
         consumersTab = new ConsumersTab(ctx);
         endpointsTab = new EndpointsTab(
@@ -437,7 +439,7 @@ public class CamelMonitor extends CamelCommand {
                     return true;
                 }
                 if (ke.isDown()) {
-                    morePopupState.selectNext(10);
+                    morePopupState.selectNext(11);
                     return true;
                 }
                 // Shortcut keys for quick selection
@@ -459,8 +461,9 @@ public class CamelMonitor extends CamelCommand {
                             case 5 -> consumersTab;
                             case 6 -> inflightTab;
                             case 7 -> memoryTab;
-                            case 8 -> startupTab;
-                            case 9 -> threadsTab;
+                            case 8 -> metricsTab;
+                            case 9 -> startupTab;
+                            case 10 -> threadsTab;
                             default -> null;
                         };
                         if (activeMoreTab != null) {
@@ -555,25 +558,25 @@ public class CamelMonitor extends CamelCommand {
                 }
                 if (!isInfraSelected()) {
                     if (ke.isChar('3')) {
-                        return handleTabKey(TAB_ROUTES);
+                        return handleTabKey(TAB_DIAGRAM);
                     }
                     if (ke.isChar('4')) {
-                        return handleTabKey(TAB_ENDPOINTS);
+                        return handleTabKey(TAB_ROUTES);
                     }
                     if (ke.isChar('5')) {
-                        return handleTabKey(TAB_HTTP);
+                        return handleTabKey(TAB_ENDPOINTS);
                     }
                     if (ke.isChar('6')) {
-                        return handleTabKey(TAB_HEALTH);
+                        return handleTabKey(TAB_HTTP);
                     }
                     if (ke.isChar('7')) {
-                        return handleTabKey(TAB_HISTORY);
+                        return handleTabKey(TAB_HEALTH);
                     }
                     if (ke.isChar('8')) {
-                        return handleTabKey(TAB_ERRORS);
+                        return handleTabKey(TAB_HISTORY);
                     }
                     if (ke.isChar('9')) {
-                        return handleTabKey(TAB_METRICS);
+                        return handleTabKey(TAB_ERRORS);
                     }
                     if (ke.isChar('0')) {
                         return handleTabKey(TAB_MORE);
@@ -783,13 +786,15 @@ public class CamelMonitor extends CamelCommand {
                 long cutoff = now - 2000;
                 recentKeys.removeIf(k -> k.timestamp() < cutoff);
             }
-            long interval = routesTab.isShowDiagram() ? Math.max(refreshInterval, 1000) : refreshInterval;
+            boolean anyDiagramShowing = routesTab.isShowDiagram() || diagramTab.isShowDiagram();
+            long interval = anyDiagramShowing ? Math.max(refreshInterval, 1000) : refreshInterval;
             if (now - lastRefresh >= interval) {
                 refreshData();
                 routesTab.refreshDiagramIfNeeded();
+                diagramTab.refreshDiagramIfNeeded();
                 return true;
             }
-            return !routesTab.hasImageDiagram();
+            return true;
         }
         return false;
     }
@@ -861,6 +866,9 @@ public class CamelMonitor extends CamelCommand {
             refreshLogData();
             logTab.onTabSelected();
         }
+        if (tab == TAB_DIAGRAM) {
+            diagramTab.onTabSelected();
+        }
         if (tab == TAB_HISTORY && ctx.selectedPid != null) {
             try {
                 long pid = Long.parseLong(ctx.selectedPid);
@@ -904,6 +912,7 @@ public class CamelMonitor extends CamelCommand {
     }
 
     private void resetIntegrationTabState() {
+        diagramTab.onIntegrationChanged();
         routesTab.onIntegrationChanged();
         httpTab.onIntegrationChanged();
         logTab.onIntegrationChanged();
@@ -1064,19 +1073,17 @@ public class CamelMonitor extends CamelCommand {
         boolean hasTraces = hasSelection && !traces.get().isEmpty();
         int httpCount = hasSelection ? sel.httpEndpoints.size() : 0;
 
-        int metricsCount = hasSelection ? sel.meters.size() : 0;
-
         // Row 0: label-only titles — fixed width so the tab bar never shifts when badges appear
         Line[] labels = {
                 Line.from(" 1 Overview "),
                 Line.from(" 2 Log "),
-                Line.from(routesTab.isTopMode() ? " 3  Top  " : " 3 Route "),
-                Line.from(" 4 Endpoint "),
-                Line.from(" 5 HTTP "),
-                Line.from(" 6 Health "),
-                Line.from(" 7 Inspect "),
-                Line.from(" 8 Errors "),
-                Line.from(" 9 Metrics "),
+                Line.from(" 3 Diagram "),
+                Line.from(routesTab.isTopMode() ? " 4  Top  " : " 4 Route "),
+                Line.from(" 5 Endpoint "),
+                Line.from(" 6 HTTP "),
+                Line.from(" 7 Health "),
+                Line.from(" 8 Inspect "),
+                Line.from(" 9 Errors "),
                 Line.from(" 0 More▾ "),
         };
         currentTabLabels = labels;
@@ -1111,6 +1118,7 @@ public class CamelMonitor extends CamelCommand {
                 badgeTexts[TAB_OVERVIEW] = "(" + activeCount + ")";
             }
             if (routeCount > 0) {
+                badgeTexts[TAB_DIAGRAM] = "(1)";
                 badgeTexts[TAB_ROUTES] = "(" + routeCount + ")";
             }
             if (endpointCount > 0) {
@@ -1131,9 +1139,6 @@ public class CamelMonitor extends CamelCommand {
             } else if (historyCount > 0) {
                 badgeTexts[TAB_HISTORY] = "(" + historyCount + ")";
             }
-            if (metricsCount > 0) {
-                badgeTexts[TAB_METRICS] = "(" + metricsCount + ")";
-            }
             if (cbOpenCount > 0) {
                 badgeTexts[TAB_MORE] = "(" + cbOpenCount + " OPEN)";
                 badgeStyles[TAB_MORE] = red;
@@ -1179,7 +1184,7 @@ public class CamelMonitor extends CamelCommand {
 
     private void renderMorePopup(Frame frame, Rect area) {
         int popupW = 22;
-        int popupH = 11;
+        int popupH = 12;
         // Position just below the "0 More▾" tab label
         int dividerW = CharWidth.of(" | ");
         int tabBarX = 0;
@@ -1209,6 +1214,7 @@ public class CamelMonitor extends CamelCommand {
                 ListItem.from(Line.from(Span.raw("  Co"), Span.styled("n", keyStyle), Span.raw("sumers"))),
                 ListItem.from(Line.from(Span.raw("  "), Span.styled("I", keyStyle), Span.raw("nflight"))),
                 ListItem.from(Line.from(Span.raw("  "), Span.styled("M", keyStyle), Span.raw("emory"))),
+                ListItem.from(Line.from(Span.raw("  M"), Span.styled("e", keyStyle), Span.raw("trics"))),
                 ListItem.from(Line.from(Span.raw("  "), Span.styled("S", keyStyle), Span.raw("tartup"))),
                 ListItem.from(Line.from(Span.raw("  "), Span.styled("T", keyStyle), Span.raw("hreads"))),
         };
@@ -1305,12 +1311,15 @@ public class CamelMonitor extends CamelCommand {
         if (ke.isChar('m')) {
             return 7;
         }
-        if (ke.isChar('s')) {
+        if (ke.isChar('e')) {
             return 8;
         }
-        if (ke.isChar('t')) {
+        if (ke.isChar('s')) {
             return 9;
         }
+        if (ke.isChar('t')) {
+            return 10;
+        }
         return -1;
     }
 
@@ -1318,13 +1327,13 @@ public class CamelMonitor extends CamelCommand {
         return switch (tabsState.selected()) {
             case TAB_OVERVIEW -> overviewTab;
             case TAB_LOG -> logTab;
+            case TAB_DIAGRAM -> diagramTab;
             case TAB_ROUTES -> routesTab;
             case TAB_ENDPOINTS -> endpointsTab;
             case TAB_HEALTH -> healthTab;
             case TAB_HISTORY -> historyTab;
             case TAB_HTTP -> httpTab;
             case TAB_ERRORS -> errorsTab;
-            case TAB_METRICS -> metricsTab;
             case TAB_MORE -> activeMoreTab;
             default -> null;
         };
@@ -2571,8 +2580,8 @@ public class CamelMonitor extends CamelCommand {
     // ---- MCP accessor methods ----
 
     private static final String[] TAB_NAMES = {
-            "Overview", "Log", "Routes", "Endpoints",
-            "HTTP", "Health", "Inspect", "Errors", "Circuit Breaker", "Consumers"
+            "Overview", "Log", "Diagram", "Routes", "Endpoints",
+            "HTTP", "Health", "Inspect", "Errors", "More"
     };
 
     Buffer getLastBuffer() {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
index ba3dddb74a0c..7d67c3475565 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
@@ -50,6 +50,13 @@ import org.apache.camel.diagram.RouteDiagramAsciiRenderer;
 import org.apache.camel.diagram.RouteDiagramHelper;
 import org.apache.camel.diagram.RouteDiagramLayoutEngine;
 import org.apache.camel.diagram.RouteDiagramRenderer;
+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;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyNodeInfo;
 import org.apache.camel.dsl.jbang.core.common.PathUtils;
 import org.apache.camel.util.json.JsonObject;
 
@@ -59,6 +66,8 @@ class DiagramSupport {
 
     private boolean showDiagram;
     private boolean diagramTextMode;
+    private boolean topologyMode;
+    private boolean showDescription;
     private List<RouteDiagramAsciiRenderer.CounterPos> counterPositions = Collections.emptyList();
     private Set<Integer> routeTitleRows = Collections.emptySet();
     private List<String> lines = Collections.emptyList();
@@ -83,6 +92,22 @@ class DiagramSupport {
         return diagramTextMode;
     }
 
+    boolean isTopologyMode() {
+        return topologyMode;
+    }
+
+    void setTopologyMode(boolean topologyMode) {
+        this.topologyMode = topologyMode;
+    }
+
+    boolean isShowDescription() {
+        return showDescription;
+    }
+
+    void setShowDescription(boolean showDescription) {
+        this.showDescription = showDescription;
+    }
+
     boolean hasDiagramData() {
         return diagramTextMode ? !lines.isEmpty() : fullImageData != null;
     }
@@ -149,6 +174,16 @@ class DiagramSupport {
         }
     }
 
+    void switchToImageMode() {
+        diagramTextMode = false;
+        showDiagram = true;
+    }
+
+    void switchToTextMode() {
+        diagramTextMode = true;
+        showDiagram = true;
+    }
+
     boolean handleEscape() {
         if (showDiagram) {
             close();
@@ -175,7 +210,7 @@ class DiagramSupport {
         hint(spans, closeKey + "/Esc", "close");
         hint(spans, "↑↓←→", "scroll");
         hint(spans, "PgUp/PgDn", "page");
-        hintLast(spans, "Home/End", "top/bottom");
+        hint(spans, "Home/End", "top/end");
     }
 
     // ---- Rendering ----
@@ -395,14 +430,104 @@ class DiagramSupport {
         renderRoutes(ctx, textMode, routes, metrics, null, null);
     }
 
+    void loadTopologyDiagramInBackground(
+            MonitorContext ctx, String pid, boolean textMode, boolean metrics) {
+        JsonObject jo = requestRouteTopology(ctx, pid);
+        if (jo == null) {
+            applyResult(ctx, List.of("(No response from integration)"), null, null, null);
+            return;
+        }
+
+        List<TopologyNodeInfo> nodes = TopologyHelper.parseNodes(jo);
+        List<TopologyEdgeInfo> edges = TopologyHelper.parseEdges(jo);
+        if (nodes.isEmpty()) {
+            applyResult(ctx, List.of("(No routes in response)"), null, null, null);
+            return;
+        }
+
+        TopologyLayoutEngine engine = new TopologyLayoutEngine();
+        TopologyLayoutResult result = engine.layout(nodes, edges);
+
+        if (textMode) {
+            TopologyAsciiRenderer renderer = new TopologyAsciiRenderer(
+                    engine.getNodeWidth(), true, metrics, showDescription);
+            String text = renderer.renderDiagramPlain(result);
+
+            List<String> resultLines = new ArrayList<>();
+            List<RouteDiagramAsciiRenderer.CounterPos> positions = new ArrayList<>();
+            String[] rawLines = text.split("\n", -1);
+            int[] rowMapping = new int[rawLines.length];
+            int newRow = 0;
+            for (int i = 0; i < rawLines.length; i++) {
+                if (!rawLines[i].isEmpty()) {
+                    rowMapping[i] = newRow++;
+                    resultLines.add(rawLines[i]);
+                } else {
+                    rowMapping[i] = -1;
+                }
+            }
+
+            for (TopologyAsciiRenderer.CounterPos cp : renderer.getCounterPositions()) {
+                if (cp.row() >= 0 && cp.row() < rowMapping.length && rowMapping[cp.row()] >= 0) {
+                    RouteDiagramAsciiRenderer.CounterType mapped = switch (cp.type()) {
+                        case OK -> RouteDiagramAsciiRenderer.CounterType.OK;
+                        case FAIL -> RouteDiagramAsciiRenderer.CounterType.FAIL;
+                        case TRIGGER -> RouteDiagramAsciiRenderer.CounterType.HIGHLIGHT_SUCCESS;
+                        case EXTERNAL -> RouteDiagramAsciiRenderer.CounterType.OK;
+                    };
+                    positions.add(new RouteDiagramAsciiRenderer.CounterPos(
+                            rowMapping[cp.row()], cp.col(), cp.length(), mapped));
+                }
+            }
+
+            applyResult(ctx, resultLines, null, null, null, positions, Collections.emptySet());
+        } else {
+            TerminalImageCapabilities caps = TerminalImageCapabilities.detect();
+            if (caps.supportsNativeImages()) {
+                RouteDiagramRenderer.DiagramColors colors
+                        = RouteDiagramRenderer.DiagramColors.parse("transparent");
+                java.awt.image.BufferedImage image = TopologyImageRenderer.renderImage(
+                        result, colors, TopologyLayoutEngine.DEFAULT_FONT_SIZE,
+                        TopologyLayoutEngine.DEFAULT_NODE_WIDTH, metrics, false);
+                ImageData full = ImageData.fromBufferedImage(image);
+                ImageData resized = full.resize(full.width() / 2, full.height() / 2);
+                ImageProtocol proto = caps.bestProtocol();
+                applyResult(ctx, Collections.emptyList(), resized, resized, proto);
+            } else {
+                applyResult(ctx, List.of(
+                        "(Terminal does not support image rendering)",
+                        "(Press Shift+D for text diagram)"), null, null, null);
+            }
+        }
+    }
+
+    private JsonObject requestRouteTopology(MonitorContext ctx, String pid) {
+        Path outputFile = ctx.getOutputFile(pid);
+        PathUtils.deleteFile(outputFile);
+
+        JsonObject root = new JsonObject();
+        root.put("action", "route-topology");
+        root.put("metric", "true");
+
+        Path actionFile = ctx.getActionFile(pid);
+        PathUtils.writeTextSafely(root.toJson(), actionFile);
+
+        JsonObject jo = pollJsonResponse(outputFile, 5000);
+        PathUtils.deleteFile(outputFile);
+        return jo;
+    }
+
     private void renderRoutes(
             MonitorContext ctx, boolean textMode,
             List<RouteDiagramLayoutEngine.RouteInfo> routes, boolean metrics,
             Set<String> highlightNodeIds, RouteDiagramHelper.HighlightStyle hlStyle) {
         if (textMode) {
+            RouteDiagramLayoutEngine.NodeLabelMode labelMode = showDescription
+                    ? RouteDiagramLayoutEngine.NodeLabelMode.DESCRIPTION
+                    : RouteDiagramLayoutEngine.NodeLabelMode.CODE;
             RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(
                     RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH, RouteDiagramLayoutEngine.DEFAULT_FONT_SIZE,
-                    RouteDiagramLayoutEngine.NodeLabelMode.CODE);
+                    labelMode);
 
             List<String> result = new ArrayList<>();
             List<RouteDiagramAsciiRenderer.CounterPos> positions = new ArrayList<>();
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
new file mode 100644
index 000000000000..0311dbad42ef
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
@@ -0,0 +1,256 @@
+/*
+ * 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.dsl.jbang.core.commands.tui;
+
+import java.util.List;
+
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.text.Text;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.paragraph.Paragraph;
+
+import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.*;
+
+class DiagramTab implements MonitorTab {
+
+    private final MonitorContext ctx;
+    private final DiagramSupport diagram = new DiagramSupport();
+    private boolean diagramMetrics = true;
+    private boolean topologyMode = true;
+    private String drillDownRouteId;
+
+    DiagramTab(MonitorContext ctx) {
+        this.ctx = ctx;
+    }
+
+    boolean isShowDiagram() {
+        return diagram.isShowDiagram();
+    }
+
+    @Override
+    public boolean handleKeyEvent(KeyEvent ke) {
+        if (diagram.handleScrollKeys(ke)) {
+            return true;
+        }
+
+        // Toggle metrics
+        if (diagram.isShowDiagram() && ke.isCharIgnoreCase('m')) {
+            diagramMetrics = !diagramMetrics;
+            diagram.endLoad();
+            reloadDiagram();
+            return true;
+        }
+
+        // Toggle description
+        if (diagram.isShowDiagram() && ke.isCharIgnoreCase('n')) {
+            diagram.setShowDescription(!diagram.isShowDescription());
+            diagram.endLoad();
+            reloadDiagram();
+            return true;
+        }
+
+        // Drill down into route diagram (Enter)
+        if (topologyMode && ke.isConfirm()) {
+            // For now, drill-down is a future feature
+            return true;
+        }
+
+        return false;
+    }
+
+    @Override
+    public boolean handleEscape() {
+        if (!topologyMode) {
+            topologyMode = true;
+            diagram.setTopologyMode(true);
+            diagram.endLoad();
+            reloadDiagram();
+            return true;
+        }
+        return diagram.handleEscape();
+    }
+
+    @Override
+    public void navigateUp() {
+        // Scroll diagram up
+    }
+
+    @Override
+    public void navigateDown() {
+        // Scroll diagram down
+    }
+
+    @Override
+    public void onTabSelected() {
+        if (!diagram.isShowDiagram()) {
+            diagram.toggleTextDiagram(this::loadDiagram);
+        }
+    }
+
+    @Override
+    public void onIntegrationChanged() {
+        topologyMode = true;
+        drillDownRouteId = null;
+        diagram.reset();
+        diagram.setTopologyMode(true);
+    }
+
+    @Override
+    public void render(Frame frame, Rect area) {
+        IntegrationInfo info = ctx.findSelectedIntegration();
+        if (info == null) {
+            renderNoSelection(frame, area);
+            return;
+        }
+
+        if (diagram.isShowDiagram() && diagram.hasDiagramData()) {
+            String title;
+            if (topologyMode) {
+                title = " Topology ";
+            } else {
+                title = " Route [" + drillDownRouteId + "] ";
+            }
+            diagram.renderDiagram(frame, area, title);
+            return;
+        }
+
+        // Show placeholder when no diagram is loaded yet
+        frame.renderWidget(
+                Paragraph.builder()
+                        .text(Text.from(Line.from(Span.styled(
+                                "Loading diagram...",
+                                Style.EMPTY.dim()))))
+                        .block(Block.builder().borderType(BorderType.ROUNDED)
+                                .title(" Diagram ").build())
+                        .build(),
+                area);
+    }
+
+    @Override
+    public void renderFooter(List<Span> spans) {
+        if (diagram.isShowDiagram()) {
+            diagram.renderFooterHints(spans);
+            hint(spans, "m", "metrics" + (diagramMetrics ? " [on]" : " [off]"));
+            hint(spans, "n", "description" + (diagram.isShowDescription() ? " [on]" : " [off]"));
+        }
+    }
+
+    void refreshDiagramIfNeeded() {
+        if (diagram.isShowDiagram() && diagramMetrics) {
+            reloadDiagram();
+        }
+    }
+
+    private void loadDiagram() {
+        loadDiagram(true);
+    }
+
+    private void reloadDiagram() {
+        loadDiagram(false);
+    }
+
+    private void loadDiagram(boolean showPlaceholder) {
+        if (ctx.selectedPid == null || ctx.runner == null) {
+            return;
+        }
+        if (!diagram.beginLoad()) {
+            return;
+        }
+
+        String pid = ctx.selectedPid;
+        boolean showMetrics = diagramMetrics;
+
+        if (showPlaceholder) {
+            diagram.setLoadingPlaceholder();
+        }
+
+        ctx.runner.scheduler().execute(() -> {
+            try {
+                if (topologyMode) {
+                    diagram.setTopologyMode(true);
+                    diagram.loadTopologyDiagramInBackground(ctx, pid, true, showMetrics);
+                } else {
+                    diagram.setTopologyMode(false);
+                    diagram.loadRouteDiagramInBackground(ctx, pid, true, drillDownRouteId, showMetrics);
+                }
+            } finally {
+                diagram.endLoad();
+            }
+        });
+    }
+
+    @Override
+    public String getHelpText() {
+        return """
+                                # Diagram
+
+                                The Diagram tab shows a visual topology of how routes connect to each other.
+                                This helps you understand the overall message flow in your integration.
+
+                                ## Topology View
+
+                                The topology view shows all routes and their connections:
+                                - **Trigger routes** (timer, cron, etc.) appear at the top
+                                - **Downstream routes** appear below, connected by arrows
+                                - Routes that are connected show edges between them
+
+                                ## Example Topology
+
+                                ```
+                                ┌──────────────────┐
+                                │ order-generator  │
+                                │  (timer://gen)   │
+                                │                  │
+                                │      3748        │
+                                └──────────────────┘
+                                         │
+                                         ▼
+                                ┌──────────────────┐
+                                │  process-order   │
+                                │ (direct:process) │
+                                │                  │
+                                │    3748/12!      │
+                                └──────────────────┘
+                                ```
+
+                                Each box represents a route. The first line is the route ID,
+                                the second line shows the `from` endpoint, and the bottom line
+                                shows metrics when enabled. Arrows show how routes connect.
+
+                                ## Metrics
+
+                                When metrics are enabled, each route box shows exchange counts:
+                                - **Green** number — successful exchanges
+                                - **Red** number with `!` — failed exchanges
+                                - Combined as `3748/12!` means 3748 ok and 12 failed
+
+                                ## Keys
+
+                                - `m` — toggle metrics on/off (default: on)
+                                - `↑↓←→` — scroll diagram
+                                - `PgUp/PgDn` — page scroll
+                                - `Home/End` — top/end
+                                - `Esc` — close diagram
+                """;
+    }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
index f2dfdc479648..0a3053303ac2 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
@@ -110,11 +110,7 @@ class HistoryTab implements MonitorTab {
         if (diagram.handleScrollKeys(ke)) {
             return true;
         }
-        if (ke.isChar('d')) {
-            diagram.toggleImageDiagram(this::loadDiagramForCurrentView);
-            return true;
-        }
-        if (ke.isChar('D')) {
+        if (ke.isCharIgnoreCase('d')) {
             diagram.toggleTextDiagram(this::loadDiagramForCurrentView);
             return true;
         }
@@ -405,7 +401,6 @@ class HistoryTab implements MonitorTab {
             hint(spans, "g", "waterfall" + (showWaterfall ? " [on]" : ""));
             if (!showWaterfall) {
                 hint(spans, "d", "diagram");
-                hint(spans, "D", "text diagram");
                 hint(spans, "p", "properties" + (showTraceProperties ? " [on]" : " [off]"));
                 hint(spans, "v", "variables" + (showTraceVariables ? " [on]" : " [off]"));
                 hint(spans, "h", "headers" + (showTraceHeaders ? " [on]" : " [off]"));
@@ -417,7 +412,6 @@ class HistoryTab implements MonitorTab {
             hint(spans, "↑↓", "navigate");
             hint(spans, "s", "sort");
             hint(spans, "d", "diagram");
-            hint(spans, "D", "text diagram");
             hint(spans, "Enter", "details");
             hintLast(spans, "F5", "refresh");
         } else {
@@ -430,7 +424,6 @@ class HistoryTab implements MonitorTab {
             hint(spans, "g", "waterfall" + (showWaterfall ? " [on]" : ""));
             if (!showWaterfall) {
                 hint(spans, "d", "diagram");
-                hint(spans, "D", "text diagram");
                 hint(spans, "p", "properties" + (showHistoryProperties ? " [on]" : " [off]"));
                 hint(spans, "v", "variables" + (showHistoryVariables ? " [on]" : " [off]"));
                 hint(spans, "h", "headers" + (showHistoryHeaders ? " [on]" : " [off]"));
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
index 766c43e966a8..295dba6e591b 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
@@ -206,17 +206,20 @@ class RoutesTab implements MonitorTab {
             return true;
         }
 
-        // Image diagram toggle
-        if (ke.isChar('d')) {
-            diagram.toggleImageDiagram(this::loadDiagramForSelectedRoute);
-            return true;
-        }
         // Text diagram toggle
-        if (ke.isChar('D')) {
+        if (ke.isCharIgnoreCase('d')) {
             diagram.toggleTextDiagram(this::loadDiagramForSelectedRoute);
             return true;
         }
 
+        // Toggle description in diagram
+        if (diagram.isShowDiagram() && ke.isCharIgnoreCase('n')) {
+            diagram.setShowDescription(!diagram.isShowDescription());
+            diagram.endLoad();
+            loadDiagramForSelectedRoute();
+            return true;
+        }
+
         // Source viewer toggle
         if (ke.isChar('c')) {
             if (showSource) {
@@ -470,10 +473,11 @@ class RoutesTab implements MonitorTab {
             hint(spans, "c/Esc", "close");
             hint(spans, "↑↓←→", "scroll");
             hint(spans, "PgUp/PgDn", "page");
-            hintLast(spans, "Home/End", "top/bottom");
+            hintLast(spans, "Home/End", "top/end");
         } else if (diagram.isShowDiagram()) {
             diagram.renderFooterHints(spans);
-            hintLast(spans, "m", "metrics" + (diagramMetrics ? " [on]" : " [off]"));
+            hint(spans, "m", "metrics" + (diagramMetrics ? " [on]" : " [off]"));
+            hintLast(spans, "n", "description" + (diagram.isShowDescription() ? " [on]" : " [off]"));
         } else {
             hint(spans, "Esc", "back");
             hint(spans, "↑↓", "navigate");
@@ -482,7 +486,6 @@ class RoutesTab implements MonitorTab {
             if (!routeTopMode) {
                 hint(spans, "c", "source");
                 hint(spans, "d", "diagram");
-                hint(spans, "D", "text diagram");
                 hint(spans, "a", "diagram " + (diagramAllRoutes ? "[all]" : "[single]"));
                 String routeState = selectedRouteState();
                 boolean supSus = selectedRouteSupportsSuspension();
@@ -1271,7 +1274,6 @@ class RoutesTab implements MonitorTab {
                 - `p` — start/stop selected route
                 - `P` — suspend/resume selected route
                 - `d` — show route diagram
-                - `D` — show text diagram
                 - `a` — toggle diagram scope (single route or all routes)
                 - `c` — show route source code
                 - `m` — toggle metrics in diagram
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StartupTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StartupTab.java
index c539ab206967..67793824d3cc 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StartupTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StartupTab.java
@@ -413,7 +413,7 @@ class StartupTab implements MonitorTab {
 
                 - `Up/Down` — scroll through steps
                 - `PgUp/PgDn` — scroll by page
-                - `Home/End` — jump to top/bottom
+                - `Home/End` — jump to top/end
                 - `F5` — reload startup data
                 - `Esc` — back
                 """;