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 75ece55a0737 CAMEL-25429: camel-jbang - the monitor draws the error 
handling in a frame of its own (#27560)
75ece55a0737 is described below

commit 75ece55a0737d314a8b3231e29d8b6918cb92bc2
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 8 15:38:47 2026 +0200

    CAMEL-25429: camel-jbang - the monitor draws the error handling in a frame 
of its own (#27560)
    
    * CAMEL-25429: camel-jbang - the monitor draws the error handling in a 
frame of its own
    
    The Diagram tab's topology shows the happy path; x shows the error
    handling too, in a frame below the routes: the routes reached only on
    error (such as the target of a dead letter channel), the dashed error
    paths into them, down a gutter on the left clear of the routes, and
    under each one where its failures come from and how they are handled
    (handled, continued, not handled). A route's own failures coming back
    to it are a line, not a loop. tui_get_topology lists the error paths and
    whether they are shown. An integration on a Camel before 4.23 reports
    no error paths, and the toggle says 4.23+.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    * CAMEL-25429: camel-jbang - the route view shows its error handling in a 
frame of its own
    
    The route view drew the onException clauses (a global one is in every
    route) inline, between from and the route's own steps, as if they were
    the route's first steps. Now the route shows its own steps only; x shows
    its error handling in a frame below them: where its failures go, where
    it is reached on error from, and its onException clauses as blocks of
    their own. The history view keeps the clauses in the route, so a replay
    can step into them.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    * CAMEL-25429: camel-jbang - F1 help of the route diagram describes x
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    * CAMEL-25429: camel-jbang - monitor docs and MCP tool text describe the 
error handling
    
    The Diagram page lists the error handling as a feature, says the route
    shows its own steps with x for its error handling, places the routes
    reached only on error, and has x in the key table. tui_get_topology
    describes errorPaths and errorsShown.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    * CAMEL-25429: camel-jbang - the error frame has no arrows; a mark on 
routes with error handling
    
    With a global error handler every route had an arrow into the error
    frame, which gets busy, and the labels in the frame already say where
    the failures come from. The frame now has no arrows; selecting a route
    in it highlights the routes that send to it, and a route with error
    handling has a dim lightning mark on its box, in the topology and the
    route view, whether the error handling is shown or not.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    * CAMEL-25429: review nits, the error labels are built once per widget; 
drop an unused method
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../ROOT/pages/camel-jbang-tui-diagram.adoc        |  25 +-
 .../jbang/core/commands/tui/DiagramSupport.java    | 343 ++++++++++++++++++++-
 .../dsl/jbang/core/commands/tui/DiagramTab.java    |  22 +-
 .../core/commands/tui/TuiToolDefinitions.java      |   2 +-
 .../core/commands/tui/diagram/ErrorLayer.java      |  54 ++++
 .../commands/tui/diagram/RouteDiagramWidget.java   |  69 ++++-
 .../core/commands/tui/diagram/RouteErrorFrame.java |  30 ++
 .../tui/diagram/TopologyDiagramWidget.java         | 130 +++++++-
 .../src/main/resources/tui/help/diagram.md         |  17 +-
 .../core/commands/tui/TopologyErrorLayerTest.java  | 190 ++++++++++++
 10 files changed, 863 insertions(+), 19 deletions(-)

diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-diagram.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-diagram.adoc
index a981a3ea065d..aa4f87e68c8a 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-diagram.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-diagram.adoc
@@ -12,6 +12,8 @@ See xref:camel-jbang-tui.adoc[Camel TUI] for getting started 
and the other pages
 * <<_architecture_view,*Architecture view*>> -- the capabilities of the 
integration and how they connect
 * <<_topology_view,*Topology view*>> -- every route and the endpoints that 
connect them, with live counts
 * <<_drill_down_into_routes,*Drill-down*>> -- the processors of one route, 
step by step
+* *Error handling* (*x*) -- the happy path by default; on, the error handling 
in a frame of its own, in the
+  topology and in a route
 * <<_external_endpoints,*External endpoints*>> -- what the integration 
consumes from and sends to outside
 * <<_metrics_and_source_code,*Metrics and source*>> -- throughput and timing 
on the boxes, and the source a box comes from
 
@@ -44,6 +46,10 @@ Nodes that connect to other routes show a *Enter* indicator 
-- press *Enter* to
 to the linked route. Navigation history is maintained as a breadcrumb stack: 
press *Esc* to go
 back to the previous route, and eventually back to the topology view.
 
+The route shows its own steps. Its onException clauses (a global one is copied 
into every route) are
+not drawn among them: press *x* to show the route's error handling in a frame 
below it (see
+<<_diagram_levels,Diagram levels>>).
+
 Press *t* to jump straight back to the topology from any depth.
 
 image::jbang/camel-tui-diagram-route.png[The checkout route drilled into, with 
the level bar on top]
@@ -83,7 +89,19 @@ description toggle, does the same. *m* metrics and *e* 
external systems apply to
 box names its group (a route group, an AI capability marked ✦, shared services 
or utility), each group
 has the border colour of its box in the architecture, and a legend at the 
bottom of the topology lists
 the groups in their colours; it is on when the project has groups. *u* shows 
or hides the utility
-routes, one setting for the architecture and the topology, so both show the 
same groups. The route
+routes, one setting for the architecture and the topology, so both show the 
same groups. *x* errors, in
+the topology, draws the error handling in a frame of its own below the routes: 
the routes reached only while
+handling a failure (such as the target of a dead letter channel), and under 
each one where its failures come
+from and how they are handled (handled: the message ends there; continued; not 
handled: the failure goes on
+to the caller). There are no arrows into the frame (with a global error 
handler every route would have one);
+selecting a route in the frame highlights the routes that send to it. Off, the 
topology shows the happy path
+only. A route with error handling has a dim ⚡ on its box, in the topology and 
in the route view, whether the
+error handling is shown or not.
+In the route view *x* does the same for one route: off, the route shows its 
own steps only, without the
+onException clauses (a global one is in every route); on, a frame below the 
route lists where its failures
+go and where it is reached on error from, and shows its onException clauses as 
blocks of their own. The
+history view always shows the onException clauses, so a replay can step into 
them.
+It needs Camel 4.23 or newer in the integration; the toggle says *4.23+* for 
an older one. The route
 boxes widen, within reason, so a label and its group fit on a line, and the 
Info panel of a route
 names its group (✦ when the AI placed it). The footer lists only actions, such 
as
 *Enter* drill-down, *c* source and *s* summary.
@@ -109,7 +127,8 @@ Each route has one role:
   every group that uses them.
 * *Utility* -- plumbing with little business meaning: a route reached only 
while handling a failure
   (from a `doCatch`, an `onException` or a dead letter channel), a route that 
only logs, or one the
-  AI marked as utility. Hidden until you press *u*.
+  AI marked as utility. Hidden until you press *u*; with *x* on, the routes 
reached only on error are shown
+  in the error handling frame of the topology instead.
 * *Other* -- routes nothing above placed yet; `/overview` groups them.
 
 A box shows how many routes the group has, its entry points (HTTP, schedules, 
consumers) and the
@@ -174,5 +193,7 @@ Press *e* to cycle through three modes for external 
endpoints:
 | *v* | Next level: Architecture, Topology, Route
 | *u* | In the architecture and the topology: show or hide the utility routes
 | *g* | In the topology: show each route's group (route group, AI capability, 
shared services, utility)
+| *x* | In the topology and a route: show or hide the error handling, in a 
frame of its own (needs Camel 4.23+
+in the integration)
 | *s* | Open the integration summary (`camel-summary.md`) in the document 
viewer, from any level
 |===
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 388262078be2..c9aed9871a3d 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
@@ -61,6 +61,8 @@ import 
org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutNode;
 import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutResult;
 import org.apache.camel.diagram.TopologyLayoutEngine.TopologyNodeInfo;
 import org.apache.camel.dsl.jbang.core.commands.ai.IntegrationSummary;
+import org.apache.camel.dsl.jbang.core.commands.tui.diagram.ErrorLayer;
+import org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteErrorFrame;
 import 
org.apache.camel.dsl.jbang.core.commands.tui.diagram.TopologyDiagramWidget;
 import org.apache.camel.util.json.JsonArray;
 import org.apache.camel.util.json.JsonObject;
@@ -92,6 +94,16 @@ class DiagramSupport {
     /** Routes left out of the topology (the utility routes when hidden), and 
what the current layout was made for. */
     private volatile Set<String> hiddenRouteIds = Set.of();
     private Set<String> layoutHidden = Set.of();
+    // the error handling drawn in a frame of its own below the routes (x); 
off shows the happy path only
+    private volatile boolean showErrorPaths;
+    private boolean layoutShowErrorPaths;
+    private ErrorLayer errorLayer;
+    // the error handling of each route, in a frame below its happy path, when 
shown (x)
+    private Map<String, RouteErrorFrame> routeErrorFrames = Map.of();
+    // the error paths of the topology, shown or not (an agent sees them in 
tui_get_topology)
+    private List<ErrorLayer.ErrorPath> errorPaths = List.of();
+    // whether the integration reports error paths: its Camel is 4.23 or newer 
(older ones report calls only)
+    private boolean errorPathsKnown = true;
     private int layoutBoxColumns = DEFAULT_BOX_COLUMNS;
     private int scrollY;
     private int scrollX;
@@ -276,6 +288,24 @@ class DiagramSupport {
         }
         result.put("edges", edgesArr);
 
+        // the error paths, shown in the diagram or not (x): sent to only when 
a route handles a failure
+        JsonArray errorArr = new JsonArray();
+        for (ErrorLayer.ErrorPath p : errorPaths) {
+            JsonObject e = new JsonObject();
+            e.put("from", p.fromRouteId());
+            e.put("to", p.toRouteId());
+            e.put("via", p.via());
+            if (p.handling() != null) {
+                e.put("handling", p.handling());
+            }
+            errorArr.add(e);
+        }
+        result.put("errorPaths", errorArr);
+        result.put("errorsShown", layoutShowErrorPaths && errorLayer != null);
+        if (!errorPathsKnown) {
+            result.put("errorPathsNote", "the integration runs a Camel before 
4.23, which does not report error paths");
+        }
+
         result.put("totalNodes", layout.nodes.size());
         result.put("totalEdges", layout.edges.size());
         return result;
@@ -902,7 +932,205 @@ class DiagramSupport {
 
     /** Whether the topology was laid out for other hidden routes or another 
box width, and needs a new load. */
     boolean isTopologyStale() {
-        return !hiddenRouteIds.equals(layoutHidden) || wantedBoxColumns() != 
layoutBoxColumns;
+        return !hiddenRouteIds.equals(layoutHidden) || wantedBoxColumns() != 
layoutBoxColumns
+                || showErrorPaths != layoutShowErrorPaths;
+    }
+
+    boolean isShowErrorPaths() {
+        return showErrorPaths;
+    }
+
+    void setShowErrorPaths(boolean show) {
+        this.showErrorPaths = show;
+    }
+
+    /** The routes with error handling (that send somewhere when they handle a 
failure), for the mark on their box. */
+    Set<String> errorMarkRouteIds() {
+        Set<String> answer = new HashSet<>();
+        for (ErrorLayer.ErrorPath p : errorPaths) {
+            answer.add(p.fromRouteId());
+        }
+        return answer;
+    }
+
+    /** The error paths of the topology last loaded, whether shown or not. */
+    List<ErrorLayer.ErrorPath> getErrorPaths() {
+        return errorPaths;
+    }
+
+    /** Whether the integration reports error paths: Camel 4.23 or newer. */
+    boolean isErrorPathsKnown() {
+        return errorPathsKnown;
+    }
+
+    /**
+     * The routes reached only on error: a target of an error path that no 
route calls. They are utility routes when the
+     * error handling is not shown, and the routes of its frame when it is.
+     */
+    static Set<String> errorOnlyRoutes(List<TopologyEdgeInfo> calls, 
List<TopologyEdgeInfo> errors) {
+        Set<String> called = new HashSet<>();
+        for (TopologyEdgeInfo e : calls) {
+            called.add(e.toRouteId);
+        }
+        Set<String> answer = new java.util.LinkedHashSet<>();
+        for (TopologyEdgeInfo e : errors) {
+            if (e.toRouteId != null && !called.contains(e.toRouteId)) {
+                answer.add(e.toRouteId);
+            }
+        }
+        return answer;
+    }
+
+    /**
+     * Places the routes reached only on error in a row below the happy path, 
under the frame title, and returns the
+     * layout with them and the error layer.
+     */
+    static PlacedErrors placeErrorRoutes(
+            TopologyLayoutResult happy, List<TopologyNodeInfo> errorNodes, 
List<ErrorLayer.ErrorPath> paths,
+            Set<String> errorRouteIds, int nodeW, int nodeH) {
+        int bottom = 0;
+        int minX = Integer.MAX_VALUE;
+        int maxLayer = -1;
+        for (TopologyLayoutNode n : happy.nodes) {
+            bottom = Math.max(bottom, n.y + n.height);
+            minX = Math.min(minX, n.x);
+            maxLayer = Math.max(maxLayer, n.layer);
+        }
+        if (minX == Integer.MAX_VALUE) {
+            minX = 0;
+        }
+        int row = 20;
+        int frameTopY = happy.nodes.isEmpty() ? 2 * row : bottom + 3 * row;
+        // the frame title, then the routes
+        int boxesY = frameTopY + 2 * row;
+        List<TopologyLayoutNode> nodes = new ArrayList<>(happy.nodes);
+        int x = minX;
+        int step = nodeW + nodeW / 2;
+        for (TopologyNodeInfo info : errorNodes) {
+            TopologyLayoutNode ln = new TopologyLayoutNode(
+                    info.routeId, info.description, info.from, info.nodeType, 
info.connectionType,
+                    x, boxesY, nodeW, nodeH, maxLayer + 1);
+            ln.exchangesTotal = info.exchangesTotal;
+            ln.exchangesFailed = info.exchangesFailed;
+            nodes.add(ln);
+            x += step;
+        }
+        // room below the boxes for the lines that say where the failures come 
from
+        int labelLines = 0;
+        for (String id : errorRouteIds) {
+            // one line per route that sends there
+            labelLines = Math.max(labelLines,
+                    (int) paths.stream().filter(p -> 
id.equals(p.toRouteId())).map(ErrorLayer.ErrorPath::fromRouteId)
+                            .distinct().count());
+        }
+        int totalHeight = Math.max(happy.totalHeight, boxesY + nodeH + 
(labelLines + 3) * row);
+        int totalWidth = Math.max(happy.totalWidth, x);
+        TopologyLayoutResult result = new TopologyLayoutResult(nodes, 
happy.edges, totalWidth, totalHeight);
+        return new PlacedErrors(result, new ErrorLayer(frameTopY, 
errorRouteIds, paths));
+    }
+
+    record PlacedErrors(TopologyLayoutResult layout, ErrorLayer layer) {
+    }
+
+    /** A route split in its happy path and its onException clauses, each a 
block of its own. */
+    record RouteSplit(RouteDiagramLayoutEngine.RouteInfo happy, 
List<RouteDiagramLayoutEngine.RouteInfo> blocks) {
+    }
+
+    /**
+     * Takes the onException clauses (a global one is copied into every route) 
out of the route: they are not steps of
+     * its happy path. Each becomes a block of its own, its levels starting at 
the clause.
+     */
+    static RouteSplit splitErrorHandling(RouteDiagramLayoutEngine.RouteInfo 
route) {
+        RouteDiagramLayoutEngine.RouteInfo happy = new 
RouteDiagramLayoutEngine.RouteInfo();
+        happy.routeId = route.routeId;
+        happy.source = route.source;
+        happy.stat = route.stat;
+        List<RouteDiagramLayoutEngine.RouteInfo> blocks = new ArrayList<>();
+        RouteDiagramLayoutEngine.RouteInfo block = null;
+        int blockLevel = -1;
+        for (RouteDiagramLayoutEngine.NodeInfo n : route.nodes) {
+            if (block != null && n.level > blockLevel) {
+                block.nodes.add(rebased(n, blockLevel));
+                continue;
+            }
+            block = null;
+            if ("onException".equals(n.type)) {
+                block = new RouteDiagramLayoutEngine.RouteInfo();
+                block.routeId = route.routeId;
+                block.source = route.source;
+                blockLevel = n.level;
+                block.nodes.add(rebased(n, blockLevel));
+                blocks.add(block);
+            } else {
+                happy.nodes.add(n);
+            }
+        }
+        return new RouteSplit(happy, blocks);
+    }
+
+    private static RouteDiagramLayoutEngine.NodeInfo 
rebased(RouteDiagramLayoutEngine.NodeInfo n, int base) {
+        RouteDiagramLayoutEngine.NodeInfo copy = new 
RouteDiagramLayoutEngine.NodeInfo();
+        copy.type = n.type;
+        copy.id = n.id;
+        copy.code = n.code;
+        copy.uri = n.uri;
+        copy.description = n.description;
+        copy.level = n.level - base;
+        copy.line = n.line;
+        copy.remote = n.remote;
+        copy.stat = n.stat;
+        return copy;
+    }
+
+    /**
+     * The lines at the top of a route's error frame: where its failures go, 
where it is reached on error from, and its
+     * own failures coming back to it.
+     */
+    static List<String> routeErrorLines(String routeId, 
List<ErrorLayer.ErrorPath> paths) {
+        Map<String, List<String>> out = new java.util.LinkedHashMap<>();
+        Map<String, List<String>> in = new java.util.LinkedHashMap<>();
+        for (ErrorLayer.ErrorPath p : paths) {
+            if (routeId.equals(p.fromRouteId())) {
+                out.computeIfAbsent(p.toRouteId(), k -> new 
ArrayList<>()).add(p.label());
+            } else if (routeId.equals(p.toRouteId())) {
+                in.computeIfAbsent(p.fromRouteId(), k -> new 
ArrayList<>()).add(p.label());
+            }
+        }
+        List<String> lines = new ArrayList<>();
+        for (Map.Entry<String, List<String>> e : out.entrySet()) {
+            String how = String.join(" \u00b7 ", e.getValue());
+            lines.add(e.getKey().equals(routeId)
+                    ? "\u26a0 own failures: " + how
+                    : "\u25b8 on failure to " + e.getKey() + ": " + how);
+        }
+        for (Map.Entry<String, List<String>> e : in.entrySet()) {
+            lines.add("\u25c2 reached when " + e.getKey() + " handles a 
failure: " + String.join(" \u00b7 ", e.getValue()));
+        }
+        return lines;
+    }
+
+    /**
+     * Lays out the error handling of a route below its happy path: a frame 
with the route's error lines, then its
+     * onException blocks under one another. Returns the frame, or null when 
the route has no error handling to show.
+     */
+    static RouteErrorFrame addErrorFrame(
+            RouteDiagramLayoutEngine engine, 
RouteDiagramLayoutEngine.LayoutRoute lr,
+            List<RouteDiagramLayoutEngine.RouteInfo> blocks, List<String> 
lines) {
+        if (blocks.isEmpty() && lines.isEmpty()) {
+            return null;
+        }
+        int row = 20;
+        int top = lr.maxY + 2 * row;
+        int y = top + (lines.size() + 1) * row;
+        for (RouteDiagramLayoutEngine.RouteInfo block : blocks) {
+            RouteDiagramLayoutEngine.LayoutRoute b = engine.layoutRoute(block, 
y);
+            lr.nodes.addAll(b.nodes);
+            lr.maxX = Math.max(lr.maxX, b.maxX);
+            y = b.maxY + 2 * row;
+        }
+        int bottom = y;
+        lr.maxY = bottom + row;
+        return new RouteErrorFrame(top, bottom, lines);
     }
 
     /** Leaves out the hidden routes, the links to and from them, and external 
systems only they used. */
@@ -920,6 +1148,28 @@ class DiagramSupport {
         nodes.removeIf(n -> n.nodeType != null && 
n.nodeType.startsWith("external") && !linked.contains(n.routeId));
     }
 
+    /**
+     * The routes to highlight: those of a capability in focus, or, when a 
route of the error frame is selected, the
+     * routes whose error handling sends to it.
+     */
+    private Set<String> highlightRouteIds() {
+        if (!focusRouteIds.isEmpty() || !layoutShowErrorPaths || errorLayer == 
null) {
+            return focusRouteIds;
+        }
+        String selected = getSelectedRouteId();
+        if (selected == null || !errorLayer.routeIds().contains(selected)) {
+            return focusRouteIds;
+        }
+        Set<String> answer = new HashSet<>();
+        answer.add(selected);
+        for (ErrorLayer.ErrorPath p : errorLayer.paths()) {
+            if (selected.equals(p.toRouteId())) {
+                answer.add(p.fromRouteId());
+            }
+        }
+        return answer;
+    }
+
     /** Highlights routes in the topology (the routes of a capability); null 
clears it. */
     void setFocus(Set<String> routeIds, String name) {
         this.focusRouteIds = routeIds != null ? routeIds : Set.of();
@@ -992,10 +1242,12 @@ class DiagramSupport {
 
         var widget = new 
org.apache.camel.dsl.jbang.core.commands.tui.diagram.TopologyDiagramWidget(
                 topologyLayout, topologyNodeWidth, selectedNodeIndex, scrollX, 
scrollY, metrics, showDescription,
-                focusRouteIds, false)
+                highlightRouteIds(), false)
                 .withAiDescriptions(aiDescriptions(), Theme.aiAssisted())
                 .withNodeLines(nodeLines)
-                .withGroups(groupTags, groupColors);
+                .withGroups(groupTags, groupColors)
+                .withErrorLayer(layoutShowErrorPaths ? errorLayer : null)
+                .withErrorMarks(errorMarkRouteIds());
 
         int totalRows = widget.getTotalRows();
         int totalCols = widget.getTotalCols();
@@ -1012,10 +1264,12 @@ class DiagramSupport {
         // Re-create widget with clamped scroll
         var finalWidget = new 
org.apache.camel.dsl.jbang.core.commands.tui.diagram.TopologyDiagramWidget(
                 topologyLayout, topologyNodeWidth, selectedNodeIndex, scrollX, 
scrollY, metrics, showDescription,
-                focusRouteIds, false)
+                highlightRouteIds(), false)
                 .withAiDescriptions(aiDescriptions(), Theme.aiAssisted())
                 .withNodeLines(nodeLines)
-                .withGroups(groupTags, groupColors);
+                .withGroups(groupTags, groupColors)
+                .withErrorLayer(layoutShowErrorPaths ? errorLayer : null)
+                .withErrorMarks(errorMarkRouteIds());
 
         List<Rect> vChunks = Layout.vertical()
                 .constraints(Constraint.fill(), Constraint.length(1))
@@ -1489,7 +1743,9 @@ class DiagramSupport {
 
         var widget = new 
org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDiagramWidget(
                 routeLayout, nw, selectedEipNodeIndex, scrollX, scrollY, 
metrics, linkable,
-                showDescription, routeDescs);
+                showDescription, routeDescs)
+                .withErrorFrame(layoutShowErrorPaths ? 
routeErrorFrames.get(currentRouteId) : null)
+                .withErrorMark(errorMarkRouteIds().contains(currentRouteId));
 
         int totalRows = widget.getTotalRows();
         int totalCols = widget.getTotalCols();
@@ -1505,7 +1761,9 @@ class DiagramSupport {
 
         var finalWidget = new 
org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDiagramWidget(
                 routeLayout, nw, selectedEipNodeIndex, scrollX, scrollY, 
metrics, linkable,
-                showDescription, routeDescs);
+                showDescription, routeDescs)
+                .withErrorFrame(layoutShowErrorPaths ? 
routeErrorFrames.get(currentRouteId) : null)
+                .withErrorMark(errorMarkRouteIds().contains(currentRouteId));
 
         List<Rect> vChunks = Layout.vertical()
                 .constraints(Constraint.fill(), Constraint.length(1))
@@ -1938,7 +2196,9 @@ class DiagramSupport {
                 false, showDescription, historyRouteIds, historyFailed)
                 .withAiDescriptions(aiDescriptions(), Theme.aiAssisted())
                 .withNodeLines(nodeLines)
-                .withGroups(groupTags, groupColors);
+                .withGroups(groupTags, groupColors)
+                .withErrorLayer(layoutShowErrorPaths ? errorLayer : null)
+                .withErrorMarks(errorMarkRouteIds());
 
         int totalRows = widget.getTotalRows();
         int totalCols = widget.getTotalCols();
@@ -1957,7 +2217,9 @@ class DiagramSupport {
                 false, showDescription, historyRouteIds, historyFailed)
                 .withAiDescriptions(aiDescriptions(), Theme.aiAssisted())
                 .withNodeLines(nodeLines)
-                .withGroups(groupTags, groupColors);
+                .withGroups(groupTags, groupColors)
+                .withErrorLayer(layoutShowErrorPaths ? errorLayer : null)
+                .withErrorMarks(errorMarkRouteIds());
 
         List<Rect> vChunks = Layout.vertical()
                 .constraints(Constraint.fill(), Constraint.length(1))
@@ -2138,9 +2400,35 @@ class DiagramSupport {
         List<TopologyLayoutNode> topoNodes = Collections.emptyList();
         List<TopologyLayoutEdge> topoEdges = Collections.emptyList();
 
+        boolean showErrors = showErrorPaths;
+        List<ErrorLayer.ErrorPath> loadedErrorPaths = List.of();
+        ErrorLayer loadedErrorLayer = null;
+        boolean loadedErrorPathsKnown = true;
         if (topoJson != null) {
             List<TopologyNodeInfo> nodes = TopologyHelper.parseNodes(topoJson);
-            List<TopologyEdgeInfo> edges = TopologyHelper.parseEdges(topoJson);
+            List<TopologyEdgeInfo> all = TopologyHelper.parseEdges(topoJson, 
true);
+            List<TopologyEdgeInfo> edges = new 
ArrayList<>(all.stream().filter(e -> !e.isErrorPath()).toList());
+            List<TopologyEdgeInfo> errors = 
all.stream().filter(TopologyEdgeInfo::isErrorPath).toList();
+            // a Camel before 4.23 says no kind of edge: it reports calls only
+            JsonArray rawEdges = topoJson.getCollection("edges");
+            loadedErrorPathsKnown = rawEdges == null || rawEdges.isEmpty()
+                    || rawEdges.stream().anyMatch(o -> o instanceof JsonObject 
jo && jo.containsKey("kind"));
+            Set<String> errorOnly = errorOnlyRoutes(edges, errors);
+            loadedErrorPaths = errors.stream()
+                    .filter(e -> !hidden.contains(e.fromRouteId) || 
errorOnly.contains(e.fromRouteId))
+                    .map(e -> new ErrorLayer.ErrorPath(e.fromRouteId, 
e.toRouteId, e.via, e.handling))
+                    .distinct().toList();
+            // shown, the routes reached only on error are in the frame 
(whether utility routes are shown or not)
+            List<TopologyNodeInfo> errorNodes = new ArrayList<>();
+            if (showErrors) {
+                for (TopologyNodeInfo n : nodes) {
+                    if (n.routeId != null && errorOnly.contains(n.routeId)) {
+                        errorNodes.add(n);
+                    }
+                }
+                nodes.removeIf(errorNodes::contains);
+                edges.removeIf(e -> errorOnly.contains(e.fromRouteId) || 
errorOnly.contains(e.toRouteId));
+            }
             if (external) {
                 TopologyHelper.addExternalEndpoints(nodes, edges, topoJson);
             }
@@ -2153,11 +2441,27 @@ class DiagramSupport {
                 topoResult = engine.layout(nodes, edges);
                 normalizeTopologyLayoutY(topoResult);
                 nodeW = engine.getNodeWidth();
+            }
+            if (showErrors && !errorNodes.isEmpty()) {
+                TopologyLayoutEngine engine = engineFor(boxColumns);
+                if (topoResult == null) {
+                    topoResult = new TopologyLayoutResult(new ArrayList<>(), 
new ArrayList<>(), 0, 0);
+                    nodeW = engine.getNodeWidth();
+                }
+                Set<String> errorIds = new java.util.LinkedHashSet<>();
+                errorNodes.forEach(n -> errorIds.add(n.routeId));
+                PlacedErrors placed = placeErrorRoutes(
+                        topoResult, errorNodes, loadedErrorPaths, errorIds, 
nodeW, engine.getNodeHeight());
+                topoResult = placed.layout();
+                loadedErrorLayer = placed.layer();
+            }
+            if (topoResult != null) {
                 topoNodes = topoResult.nodes;
                 topoEdges = topoResult.edges;
             }
         }
 
+        Map<String, RouteErrorFrame> loadedRouteFrames = new 
java.util.HashMap<>();
         // Route structure is included in the topology response (routes=true)
         java.util.Map<String, RouteDiagramLayoutEngine.LayoutRoute> routeMap = 
new java.util.LinkedHashMap<>();
         if (topoJson != null) {
@@ -2189,8 +2493,17 @@ class DiagramSupport {
                         labelMode);
                 engine.setTableLayout(true);
                 for (RouteDiagramLayoutEngine.RouteInfo r : routes) {
-                    RouteDiagramLayoutEngine.LayoutRoute lr = 
engine.layoutRoute(r, 0);
+                    // the happy path; the onException clauses are not its 
steps, and are shown in the error frame
+                    RouteSplit split = splitErrorHandling(r);
+                    RouteDiagramLayoutEngine.LayoutRoute lr = 
engine.layoutRoute(split.happy(), 0);
                     normalizeRouteLayoutY(lr);
+                    if (showErrors) {
+                        RouteErrorFrame frame = addErrorFrame(
+                                engine, lr, split.blocks(), 
routeErrorLines(r.routeId, loadedErrorPaths));
+                        if (frame != null) {
+                            loadedRouteFrames.put(r.routeId, frame);
+                        }
+                    }
                     routeMap.put(r.routeId, lr);
                 }
             }
@@ -2205,9 +2518,17 @@ class DiagramSupport {
         if (ctx.runner == null) {
             return;
         }
+        List<ErrorLayer.ErrorPath> finalErrorPaths = loadedErrorPaths;
+        ErrorLayer finalErrorLayer = loadedErrorLayer;
+        boolean finalErrorPathsKnown = loadedErrorPathsKnown;
         ctx.runner.runOnRenderThread(() -> {
             layoutHidden = hidden;
             layoutBoxColumns = boxColumns;
+            layoutShowErrorPaths = showErrors;
+            errorPaths = finalErrorPaths;
+            errorLayer = finalErrorLayer;
+            routeErrorFrames = loadedRouteFrames;
+            errorPathsKnown = finalErrorPathsKnown;
             if (!showDiagram && !preloading) {
                 return;
             }
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
index f84f155b1962..d3d65c194a00 100644
--- 
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
@@ -322,6 +322,16 @@ class DiagramTab extends AbstractTab {
             return true;
         }
 
+        // Error handling: the routes reached on error and the error paths 
into them, in a frame below the happy path
+        if (diagram.isShowDiagram() && ke.isCharIgnoreCase('x') && 
(topologyMode || drillDownRouteId != null)) {
+            diagram.setShowErrorPaths(!diagram.isShowErrorPaths());
+            if (!topologyMode) {
+                // the route view has no topology to prepare: load again for 
the route's error frame
+                reloadDiagram();
+            }
+            return true;
+        }
+
         // Utility routes: one setting for the architecture and the topology, 
so both show the same groups
         if (topologyMode && diagram.isShowDiagram() && 
ke.isCharIgnoreCase('u') && hasUtilityRoutes()) {
             architecture.setShowUtility(!architecture.isShowUtility());
@@ -719,6 +729,14 @@ class DiagramTab extends AbstractTab {
         return toggles;
     }
 
+    /** On or off; an integration on a Camel before 4.23 reports no error 
paths, and the toggle says so. */
+    private String errorsToggle() {
+        if (!diagram.isErrorPathsKnown()) {
+            return "4.23+";
+        }
+        return diagram.isShowErrorPaths() ? "on" : "off";
+    }
+
     private List<SubViewBar.Toggle> levelToggles() {
         SubViewBar.Toggle view
                 = new SubViewBar.Toggle("b", "view", 
diagram.isShowDescription() ? "business" : "technical");
@@ -734,6 +752,7 @@ class DiagramTab extends AbstractTab {
                 if (hasUtilityRoutes()) {
                     toggles.add(new SubViewBar.Toggle("u", "utility", 
architecture.isShowUtility() ? "on" : "off"));
                 }
+                toggles.add(new SubViewBar.Toggle("x", "errors", 
errorsToggle()));
                 toggles.add(new SubViewBar.Toggle("m", "metrics", metrics));
                 toggles.add(new SubViewBar.Toggle(
                         "e", "external",
@@ -744,7 +763,8 @@ class DiagramTab extends AbstractTab {
                         }));
                 yield toggles;
             }
-            case ROUTE -> List.of(view, new SubViewBar.Toggle("m", "metrics", 
metrics),
+            case ROUTE -> List.of(view, new SubViewBar.Toggle("x", "errors", 
errorsToggle()),
+                    new SubViewBar.Toggle("m", "metrics", metrics),
                     new SubViewBar.Toggle("d", "detail", detailMode ? "on" : 
"off"));
         };
     }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
index 465f0a6271b4..9770a38e3cf5 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
@@ -319,7 +319,7 @@ final class TuiToolDefinitions {
                                     + "Each node has: routeId, nodeType 
(route/external-in/external-out/trigger), "
                                     + "layer, description, from (consumer 
URI), exchangesTotal, exchangesFailed. "
                                     + "Each edge has: from (routeId), to 
(routeId), endpoint, connectionType, "
-                                    + "selfLoop, backEdge. "
+                                    + "selfLoop, backEdge. errorPaths (from, 
to, via, handling): sent to only on failure. "
                                     + "Use this instead of tui_get_diagram 
when you need to reason about "
                                     + "route connectivity programmatically.",
                 Map.of())));
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/ErrorLayer.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/ErrorLayer.java
new file mode 100644
index 000000000000..be885775d94a
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/ErrorLayer.java
@@ -0,0 +1,54 @@
+/*
+ * 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.diagram;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * The error handling of a topology, drawn in a frame of its own below the 
routes: the routes reached only when a
+ * failure is handled, and the error paths into them.
+ *
+ * @param frameTopY where the frame starts, in layout units (below the lowest 
route of the happy path)
+ * @param routeIds  the routes in the frame: reached only on error
+ * @param paths     the error paths, into the routes of the frame and between 
routes of the happy path
+ */
+public record ErrorLayer(int frameTopY, Set<String> routeIds, List<ErrorPath> 
paths) {
+
+    /**
+     * An error path: the route sends to the target only when it handles a 
failure.
+     *
+     * @param via      what sends: errorHandler (a dead letter channel) or 
onException
+     * @param handling what happens to the failure: handled, continued, 
notHandled, or null when a predicate decides
+     */
+    public record ErrorPath(String fromRouteId, String toRouteId, String via, 
String handling) {
+
+        /** How the path reads in the frame: "dead letter, handled", 
"onException, not handled", ... */
+        public String label() {
+            String how = "errorHandler".equals(via) ? "dead letter" : via;
+            String what = handling == null
+                    ? "decided at runtime"
+                    : switch (handling) {
+                        case "handled" -> "handled";
+                        case "continued" -> "continued";
+                        case "notHandled" -> "not handled, goes on to the 
caller";
+                        default -> handling;
+                    };
+            return how + ", " + what;
+        }
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
index ff92bbd4f79d..1c2e9b1ecdd5 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
@@ -149,6 +149,10 @@ public class RouteDiagramWidget implements Widget {
     public void render(Rect area, Buffer buffer) {
         nodeBoxes.clear();
 
+        if (errorFrame != null) {
+            drawErrorFrame(buffer, area);
+        }
+
         // Scope boxes (behind everything)
         for (LayoutNode ln : layoutRoute.nodes) {
             if (!"route".equals(ln.type)
@@ -186,6 +190,9 @@ public class RouteDiagramWidget implements Widget {
         for (Marker m : markers) {
             writeMarker(buffer, area, m);
         }
+        if (errorMark) {
+            drawErrorMark(buffer, area);
+        }
     }
 
     /**
@@ -235,12 +242,72 @@ public class RouteDiagramWidget implements Widget {
         }
     }
 
+    private RouteErrorFrame errorFrame;
+    private boolean errorMark;
+
+    /** The route has error handling: a dim mark on its from box, whether the 
error handling is shown or not. */
+    public RouteDiagramWidget withErrorMark(boolean mark) {
+        this.errorMark = mark;
+        return this;
+    }
+
+    /** The mark on the top border of the from box, at its right end (the 
route has error handling). */
+    private void drawErrorMark(Buffer buffer, Rect area) {
+        for (LayoutNode ln : layoutRoute.nodes) {
+            if ("from".equals(ln.type)) {
+                int col = toCol(ln.x) + boxWidth - 3;
+                writeText(buffer, area, toRow(ln.y), col, "\u26a1",
+                        
org.apache.camel.dsl.jbang.core.commands.tui.Theme.warning().dim());
+                return;
+            }
+        }
+    }
+
+    /**
+     * The error handling of the route, in a frame below its happy path (its 
onException blocks are laid out in it).
+     */
+    public RouteDiagramWidget withErrorFrame(RouteErrorFrame frame) {
+        this.errorFrame = frame;
+        return this;
+    }
+
+    private void drawErrorFrame(Buffer buffer, Rect area) {
+        int top = toRow(errorFrame.topY());
+        int bottom = toRow(errorFrame.bottomY());
+        int right = getTotalCols() - 2;
+        Style s = 
org.apache.camel.dsl.jbang.core.commands.tui.Theme.warning().dim();
+        for (int c = 1; c < right; c++) {
+            setChar(buffer, area, top, c, SCOPE_H, s);
+            setChar(buffer, area, bottom, c, SCOPE_H, s);
+        }
+        for (int r = top + 1; r < bottom; r++) {
+            setChar(buffer, area, r, 0, SCOPE_V, s);
+            setChar(buffer, area, r, right, SCOPE_V, s);
+        }
+        setChar(buffer, area, top, 0, '\u256d', s);
+        setChar(buffer, area, top, right, '\u256e', s);
+        setChar(buffer, area, bottom, 0, '\u2570', s);
+        setChar(buffer, area, bottom, right, '\u256f', s);
+        writeText(buffer, area, top, 2, " Error handling ",
+                
org.apache.camel.dsl.jbang.core.commands.tui.Theme.warning().bold());
+        int row = top + 1;
+        for (String line : errorFrame.lines()) {
+            writeText(buffer, area, row++, 2, line, 
org.apache.camel.dsl.jbang.core.commands.tui.Theme.warning());
+        }
+    }
+
     public int getTotalRows() {
         return toRow(layoutRoute.maxY) + 10;
     }
 
     public int getTotalCols() {
-        return toCol(layoutRoute.maxX + PADDING) + boxWidth + 4;
+        int cols = toCol(layoutRoute.maxX + PADDING) + boxWidth + 4;
+        if (errorFrame != null) {
+            for (String line : errorFrame.lines()) {
+                cols = Math.max(cols, line.length() + 6);
+            }
+        }
+        return cols;
     }
 
     private void drawNode(Buffer buffer, Rect area, LayoutNode node) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteErrorFrame.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteErrorFrame.java
new file mode 100644
index 000000000000..191dee30206c
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteErrorFrame.java
@@ -0,0 +1,30 @@
+/*
+ * 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.diagram;
+
+import java.util.List;
+
+/**
+ * The error handling of one route, drawn in a frame below its happy path: 
lines saying where its failures go and where
+ * it is reached on error from, then its onException clauses as blocks of 
their own.
+ *
+ * @param topY    the top of the frame, in layout units
+ * @param bottomY the bottom of the frame, in layout units
+ * @param lines   the lines at the top of the frame
+ */
+public record RouteErrorFrame(int topY, int bottomY, List<String> lines) {
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyDiagramWidget.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyDiagramWidget.java
index ce686ae2031c..f4b4265ec5b8 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyDiagramWidget.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyDiagramWidget.java
@@ -59,6 +59,9 @@ public class TopologyDiagramWidget implements Widget {
     private Map<String, NodeLine> groupTags = Map.of();
     private Map<String, Color> groupColors = Map.of();
     private Style aiStyle = Style.EMPTY.italic();
+    private ErrorLayer errorLayer;
+    private Set<String> errorMarks = Set.of();
+    private Map<TopologyLayoutNode, List<String>> errorLabels;
 
     public record NodeBox(String routeId, int startRow, int endRow, int 
startCol, int endCol, int layer) {
     }
@@ -163,7 +166,8 @@ public class TopologyDiagramWidget implements Widget {
         } else {
             line1 = node.routeId;
         }
-        NodeLine tag = ext || node.routeId == null ? null : 
groupTags.get(node.routeId);
+        // a route in the error frame needs no group tag: the frame says what 
it is
+        NodeLine tag = ext || node.routeId == null || inFrame(node) ? null : 
groupTags.get(node.routeId);
 
         List<String> lines = new ArrayList<>(wrapText(line1, boxWidth - 4));
         int aiLines = ai != null ? lines.size() : 0;
@@ -231,6 +235,23 @@ public class TopologyDiagramWidget implements Widget {
         return own == null || own.isEmpty() ? null : own.subList(0, 
Math.min(own.size(), MAX_WRAP_LINES + 1));
     }
 
+    /**
+     * The error handling, drawn in a frame of its own below the routes: the 
routes reached only on error, and under
+     * each one where its failures come from (no arrows: with a global error 
handler every route would have one). A
+     * route that sends to the frame has a mark on its box.
+     */
+    public TopologyDiagramWidget withErrorLayer(ErrorLayer layer) {
+        this.errorLayer = layer;
+        this.errorLabels = null;
+        return this;
+    }
+
+    /** The routes with error handling: a dim mark on their box, whether the 
error handling is shown or not. */
+    public TopologyDiagramWidget withErrorMarks(Set<String> routeIds) {
+        this.errorMarks = routeIds != null ? routeIds : Set.of();
+        return this;
+    }
+
     public List<NodeBox> getNodeBoxes() {
         return nodeBoxes;
     }
@@ -239,6 +260,10 @@ public class TopologyDiagramWidget implements Widget {
     public void render(Rect area, Buffer buffer) {
         nodeBoxes.clear();
 
+        if (errorLayer != null) {
+            drawErrorFrame(buffer, area);
+        }
+
         for (TopologyLayoutEdge edge : layout.edges) {
             if (!edge.selfLoop) {
                 drawEdge(buffer, area, edge);
@@ -254,6 +279,107 @@ public class TopologyDiagramWidget implements Widget {
         for (TopologyLayoutNode node : layout.nodes) {
             drawNode(buffer, area, node);
         }
+
+        if (errorLayer != null) {
+            drawErrorLabels(buffer, area);
+        }
+        drawErrorMarks(buffer, area);
+    }
+
+    /** A mark on the border of each route with error handling (shown in the 
frame, or hidden until x). */
+    private void drawErrorMarks(Buffer buffer, Rect area) {
+        for (TopologyLayoutNode node : layout.nodes) {
+            if (node.routeId != null && errorMarks.contains(node.routeId) && 
!inFrame(node)) {
+                int col = toCol(node.x) + boxWidth - 3;
+                writeText(buffer, area, toRow(node.y), col, "\u26a1", 
errorStyle().dim());
+            }
+        }
+    }
+
+    private Style errorStyle() {
+        return Theme.warning();
+    }
+
+    private boolean inFrame(TopologyLayoutNode node) {
+        return errorLayer != null && node.routeId != null && 
errorLayer.routeIds().contains(node.routeId);
+    }
+
+    /** The frame of the error handling: from its top to the bottom of the 
diagram, as wide as the diagram. */
+    private void drawErrorFrame(Buffer buffer, Rect area) {
+        int top = toRow(errorLayer.frameTopY());
+        int bottom = toRow(layout.totalHeight) - 1;
+        int right = getTotalCols() - 2;
+        Style s = errorStyle().dim();
+        for (int c = 1; c < right; c++) {
+            setChar(buffer, area, top, c, DASH_H, s);
+            setChar(buffer, area, bottom, c, DASH_H, s);
+        }
+        for (int r = top + 1; r < bottom; r++) {
+            setChar(buffer, area, r, 0, DASH_V, s);
+            setChar(buffer, area, r, right, DASH_V, s);
+        }
+        setChar(buffer, area, top, 0, '\u256d', s);
+        setChar(buffer, area, top, right, '\u256e', s);
+        setChar(buffer, area, bottom, 0, '\u2570', s);
+        setChar(buffer, area, bottom, right, '\u256f', s);
+        writeText(buffer, area, top, 2, " Error handling ", 
errorStyle().bold());
+    }
+
+    /**
+     * The label lines under each route of the frame: where its failures come 
from and how they are handled, one line
+     * per route that sends there.
+     */
+    private Map<TopologyLayoutNode, List<String>> errorLabels() {
+        // the same for every use in a widget (a widget is made for one frame)
+        if (errorLabels != null) {
+            return errorLabels;
+        }
+        Map<TopologyLayoutNode, List<String>> answer = new 
java.util.LinkedHashMap<>();
+        errorLabels = answer;
+        if (errorLayer == null) {
+            return answer;
+        }
+        for (TopologyLayoutNode node : layout.nodes) {
+            if (!inFrame(node)) {
+                continue;
+            }
+            Map<String, List<String>> bySource = new 
java.util.LinkedHashMap<>();
+            for (ErrorLayer.ErrorPath p : errorLayer.paths()) {
+                if (node.routeId.equals(p.toRouteId())) {
+                    bySource.computeIfAbsent(p.fromRouteId(), k -> new 
ArrayList<>()).add(p.label());
+                }
+            }
+            List<String> lines = new ArrayList<>();
+            for (Map.Entry<String, List<String>> e : bySource.entrySet()) {
+                String how = String.join(" \u00b7 ", e.getValue());
+                lines.add(e.getKey().equals(node.routeId)
+                        ? "\u26a0 own failures: " + how
+                        : "\u25c2 " + e.getKey() + ": " + how);
+            }
+            answer.put(node, lines);
+        }
+        return answer;
+    }
+
+    private void drawErrorLabels(Buffer buffer, Rect area) {
+        for (Map.Entry<TopologyLayoutNode, List<String>> e : 
errorLabels().entrySet()) {
+            int row = toRow(e.getKey().y) + boxHeight(e.getKey());
+            int col = toCol(e.getKey().x);
+            for (String line : e.getValue()) {
+                writeText(buffer, area, row++, col, line, errorStyle());
+            }
+        }
+    }
+
+    /** The column right of the widest label of the error frame, so the frame 
and the scrolling take it in. */
+    private int errorLabelsRight() {
+        int right = 0;
+        for (Map.Entry<TopologyLayoutNode, List<String>> e : 
errorLabels().entrySet()) {
+            for (String line : e.getValue()) {
+                right = Math.max(right, toCol(e.getKey().x) + line.length() + 
2);
+            }
+        }
+        return right;
     }
 
     public int getTotalRows() {
@@ -261,7 +387,7 @@ public class TopologyDiagramWidget implements Widget {
     }
 
     public int getTotalCols() {
-        return toCol(layout.totalWidth) + boxWidth + 4;
+        return Math.max(toCol(layout.totalWidth) + boxWidth + 4, 
errorLabelsRight() + 2);
     }
 
     private void drawNode(Buffer buffer, Rect area, TopologyLayoutNode node) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/diagram.md 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/diagram.md
index 316a3a87cef6..17e1654825fc 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/diagram.md
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/diagram.md
@@ -83,7 +83,8 @@ overview setting; with no summary yet it has the AI write 
one, as
 `/overview` does), `b` view (business: labels and what capabilities achieve;
 technical: route ids and endpoints), `m` metrics, `e` external, `d`
 detail, `g` group, `u` utility (one setting for the architecture
-and the topology, so both show the same groups). The footer lists
+and the topology, so both show the same groups), `x` errors (the
+error handling in a frame below the routes). The footer lists
 only actions.
 
 ## Architecture View
@@ -137,6 +138,12 @@ linked route's diagram.
 Navigation history is maintained as a stack: pressing `Esc` goes
 back to the previous route, and eventually back to the topology view.
 
+The route shows its own steps (the happy path). Its error handling,
+the onException clauses (a global one is in every route) and where
+its failures go, is in a frame below the route when `x` is on. The
+history view always shows the onException clauses, so a replay can
+step into them.
+
 ## Route Structure Preview
 
 A compact tree structure preview appears in the bottom-right corner
@@ -159,6 +166,13 @@ you an at-a-glance view of where you are in the route.
 - `g` — show each route's group in the topology, its colour as in
   the architecture, with a legend of the groups at the bottom
 - `u` — show or hide utility routes (as in the architecture)
+- `x` — show or hide the error handling: the routes reached only on
+  error, in a frame below the routes, with where each one's failures
+  come from and how they are handled (select one to highlight the
+  routes that send to it; a route with error handling has a dim ⚡ on
+  its box; needs Camel 4.23+ in the integration);
+  in the route view, the route's onException clauses and where its
+  failures go, in a frame below its steps
 - `s` — open the integration summary (camel-summary.md)
 - `Esc` — close diagram
 
@@ -175,6 +189,7 @@ you an at-a-glance view of where you are in the route.
 - `Enter` — jump to linked route (when `↵` indicator shown)
 - `c` — show source code at selected node
 - `d` — toggle EIP detail panel (shows configured options)
+- `x` — show or hide the route's error handling, in a frame below it
 - `g` — go to node (fuzzy search popup)
 - `Esc` — go back (previous route or topology)
 - `t` — jump back to topology view
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TopologyErrorLayerTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TopologyErrorLayerTest.java
new file mode 100644
index 000000000000..ca4250bd8867
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TopologyErrorLayerTest.java
@@ -0,0 +1,190 @@
+/*
+ * 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.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine;
+import org.apache.camel.diagram.TopologyLayoutEngine;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyEdgeInfo;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutResult;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyNodeInfo;
+import org.apache.camel.dsl.jbang.core.commands.tui.diagram.ErrorLayer;
+import 
org.apache.camel.dsl.jbang.core.commands.tui.diagram.ErrorLayer.ErrorPath;
+import 
org.apache.camel.dsl.jbang.core.commands.tui.diagram.TopologyDiagramWidget;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-25429: the error handling of the topology, in a frame of its own 
below the happy path (x): the routes reached
+ * only on error, the error paths into them, and where each one's failures 
come from.
+ */
+class TopologyErrorLayerTest {
+
+    // fail-well/error-handling: checkout calls payment-provider; a dead 
letter channel and an onException send to parked
+    private final TopologyEdgeInfo charge = edge("checkout", 
"payment-provider", "call", null, null);
+    private final List<TopologyEdgeInfo> errors = List.of(
+            edge("checkout", "parked", "error", "onException", "handled"),
+            edge("checkout", "parked", "error", "errorHandler", "handled"),
+            edge("parked", "parked", "error", "errorHandler", "handled"));
+
+    @Test
+    void theRoutesReachedOnlyOnError() {
+        assertThat(DiagramSupport.errorOnlyRoutes(List.of(charge), 
errors)).containsExactly("parked");
+        // a route that is also called is on the happy path
+        assertThat(DiagramSupport.errorOnlyRoutes(
+                List.of(charge, edge("checkout", "parked", "call", null, 
null)), errors)).isEmpty();
+    }
+
+    @Test
+    void theLabelSaysWhatHappensToTheFailure() {
+        assertThat(new ErrorPath("a", "b", "errorHandler", 
"handled").label()).isEqualTo("dead letter, handled");
+        assertThat(new ErrorPath("a", "b", "onException", 
"notHandled").label())
+                .isEqualTo("onException, not handled, goes on to the caller");
+        assertThat(new ErrorPath("a", "b", "onException", 
null).label()).isEqualTo("onException, decided at runtime");
+    }
+
+    @Test
+    void theErrorRoutesAreDrawnInAFrameBelowTheHappyPath() {
+        TopologyLayoutEngine engine = new TopologyLayoutEngine();
+        TopologyLayoutResult happy = engine.layout(
+                new ArrayList<>(List.of(node("checkout", "file:orders"), 
node("payment-provider", "direct:charge"))),
+                new ArrayList<>(List.of(charge)));
+        List<ErrorPath> paths = errors.stream()
+                .map(e -> new ErrorPath(e.fromRouteId, e.toRouteId, e.via, 
e.handling)).toList();
+        DiagramSupport.PlacedErrors placed = DiagramSupport.placeErrorRoutes(
+                happy, List.of(node("parked", "direct:parked")), paths, 
Set.of("parked"), engine.getNodeWidth(),
+                engine.getNodeHeight());
+
+        int happyBottom = happy.nodes.stream().mapToInt(n -> n.y + 
n.height).max().orElseThrow();
+        assertThat(placed.layout().nodes).extracting(n -> n.routeId)
+                .containsExactlyInAnyOrder("checkout", "payment-provider", 
"parked");
+        assertThat(placed.layout().nodes).filteredOn(n -> 
"parked".equals(n.routeId))
+                .allMatch(n -> n.y > happyBottom);
+        assertThat(placed.layer().frameTopY()).isGreaterThan(happyBottom);
+
+        String screen = render(placed.layout(), engine.getNodeWidth(), 
placed.layer());
+        assertThat(screen).contains("Error handling");
+        assertThat(screen).contains("◂ checkout: onException, handled · dead 
letter, handled");
+        assertThat(screen).contains("⚠ own failures: dead letter, handled");
+        assertThat(screen).contains("parked");
+    }
+
+    @Test
+    void aRouteWithErrorHandlingHasAMarkWhetherShownOrNot() {
+        TopologyLayoutEngine engine = new TopologyLayoutEngine();
+        TopologyLayoutResult happy = engine.layout(
+                new ArrayList<>(List.of(node("checkout", "file:orders"), 
node("payment-provider", "direct:charge"))),
+                new ArrayList<>(List.of(charge)));
+        TopologyDiagramWidget widget = new TopologyDiagramWidget(happy, 
engine.getNodeWidth(), -1, 0, 0, false, false)
+                .withErrorMarks(Set.of("checkout"));
+        Rect area = new Rect(0, 0, 100, widget.getTotalRows());
+        Buffer buffer = Buffer.empty(area);
+        widget.render(area, buffer);
+
+        String screen = TuiTestHelper.bufferToString(buffer);
+        assertThat(screen).contains("\u26a1").doesNotContain("Error handling");
+        // one mark: payment-provider has no error handling
+        assertThat(screen.chars().filter(c -> c == 
'\u26a1').count()).isEqualTo(1);
+    }
+
+    @Test
+    void withoutTheLayerTheHappyPathOnly() {
+        TopologyLayoutEngine engine = new TopologyLayoutEngine();
+        TopologyLayoutResult happy = engine.layout(
+                new ArrayList<>(List.of(node("checkout", "file:orders"), 
node("payment-provider", "direct:charge"))),
+                new ArrayList<>(List.of(charge)));
+
+        String screen = render(happy, engine.getNodeWidth(), null);
+        assertThat(screen).doesNotContain("Error 
handling").contains("checkout").contains("payment-provider");
+    }
+
+    @Test
+    void theRouteViewSplitsTheOnExceptionClausesFromTheHappyPath() {
+        RouteDiagramLayoutEngine.RouteInfo route = new 
RouteDiagramLayoutEngine.RouteInfo();
+        route.routeId = "checkout";
+        route.nodes.add(step("from", 0));
+        route.nodes.add(step("onException", 1));
+        route.nodes.add(step("log", 2));
+        route.nodes.add(step("to", 2));
+        route.nodes.add(step("unmarshal", 1));
+        route.nodes.add(step("to", 1));
+
+        DiagramSupport.RouteSplit split = 
DiagramSupport.splitErrorHandling(route);
+
+        assertThat(split.happy().nodes).extracting(n -> 
n.type).containsExactly("from", "unmarshal", "to");
+        assertThat(split.blocks()).hasSize(1);
+        // the clause starts its own block
+        assertThat(split.blocks().get(0).nodes).extracting(n -> n.type + 
n.level)
+                .containsExactly("onException0", "log1", "to1");
+    }
+
+    @Test
+    void theRouteViewLinesSayWhereFailuresGoAndComeFrom() {
+        List<ErrorPath> paths = errors.stream()
+                .map(e -> new ErrorPath(e.fromRouteId, e.toRouteId, e.via, 
e.handling)).toList();
+
+        assertThat(DiagramSupport.routeErrorLines("checkout", paths))
+                .containsExactly("\u25b8 on failure to parked: onException, 
handled \u00b7 dead letter, handled");
+        assertThat(DiagramSupport.routeErrorLines("parked", 
paths)).containsExactly(
+                "\u26a0 own failures: dead letter, handled",
+                "\u25c2 reached when checkout handles a failure: onException, 
handled \u00b7 dead letter, handled");
+        assertThat(DiagramSupport.routeErrorLines("payment-provider", 
paths)).isEmpty();
+    }
+
+    private static RouteDiagramLayoutEngine.NodeInfo step(String type, int 
level) {
+        RouteDiagramLayoutEngine.NodeInfo n = new 
RouteDiagramLayoutEngine.NodeInfo();
+        n.type = type;
+        n.code = type;
+        n.level = level;
+        return n;
+    }
+
+    private static String render(TopologyLayoutResult layout, int nodeWidth, 
ErrorLayer layer) {
+        TopologyDiagramWidget widget = new TopologyDiagramWidget(layout, 
nodeWidth, -1, 0, 0, false, false)
+                .withErrorLayer(layer);
+        Rect area = new Rect(0, 0, Math.max(100, widget.getTotalCols()), 
widget.getTotalRows());
+        Buffer buffer = Buffer.empty(area);
+        widget.render(area, buffer);
+        return TuiTestHelper.bufferToString(buffer);
+    }
+
+    private static TopologyNodeInfo node(String routeId, String from) {
+        TopologyNodeInfo n = new TopologyNodeInfo();
+        n.routeId = routeId;
+        n.from = from;
+        n.nodeType = "route";
+        return n;
+    }
+
+    private static TopologyEdgeInfo edge(String from, String to, String kind, 
String via, String handling) {
+        TopologyEdgeInfo e = new TopologyEdgeInfo();
+        e.fromRouteId = from;
+        e.toRouteId = to;
+        e.endpoint = "direct:" + to;
+        e.connectionType = "internal";
+        e.kind = kind;
+        e.via = via;
+        e.handling = handling;
+        return e;
+    }
+}

Reply via email to