This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch feature/CAMEL-25429-error-layer
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 77c7cf956d57e4328c03b694b921e01bdff4e40a
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 8 11:34:03 2026 +0200

    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
---
 .../ROOT/pages/camel-jbang-tui-diagram.adoc        |   7 +-
 .../jbang/core/commands/tui/DiagramSupport.java    | 183 ++++++++++++++++++++-
 .../dsl/jbang/core/commands/tui/DiagramTab.java    |  15 ++
 .../core/commands/tui/diagram/ErrorLayer.java      |  55 +++++++
 .../tui/diagram/TopologyDiagramWidget.java         | 168 ++++++++++++++++++-
 .../src/main/resources/tui/help/diagram.md         |   6 +-
 .../core/commands/tui/TopologyErrorLayerTest.java  | 130 +++++++++++++++
 7 files changed, 553 insertions(+), 11 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..e85cf0649e31 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
@@ -83,7 +83,12 @@ 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), the dashed 
error paths into them, 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). Off, the topology 
shows the happy path only.
+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.
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..42c992bbec0d 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,7 @@ 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.TopologyDiagramWidget;
 import org.apache.camel.util.json.JsonArray;
 import org.apache.camel.util.json.JsonObject;
@@ -92,6 +93,14 @@ 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 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 +285,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 +929,99 @@ 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 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, 
leaving room above them for the frame
+     * title and one channel row per route an arrow comes into, 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;
+        // one channel row for each route of the frame that a route above 
sends to
+        Set<String> fromAbove = new java.util.LinkedHashSet<>();
+        for (ErrorLayer.ErrorPath p : paths) {
+            if (errorRouteIds.contains(p.toRouteId()) && 
!errorRouteIds.contains(p.fromRouteId())) {
+                fromAbove.add(p.toRouteId());
+            }
+        }
+        int channelsY = frameTopY + 2 * row;
+        int boxesY = channelsY + (fromAbove.size() + 1) * 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) {
+            labelLines = Math.max(labelLines, (int) paths.stream().filter(p -> 
id.equals(p.toRouteId())).count() + 1);
+        }
+        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, channelsY));
+    }
+
+    record PlacedErrors(TopologyLayoutResult layout, ErrorLayer layer) {
     }
 
     /** Leaves out the hidden routes, the links to and from them, and external 
systems only they used. */
@@ -995,7 +1114,8 @@ class DiagramSupport {
                 focusRouteIds, false)
                 .withAiDescriptions(aiDescriptions(), Theme.aiAssisted())
                 .withNodeLines(nodeLines)
-                .withGroups(groupTags, groupColors);
+                .withGroups(groupTags, groupColors)
+                .withErrorLayer(layoutShowErrorPaths ? errorLayer : null);
 
         int totalRows = widget.getTotalRows();
         int totalCols = widget.getTotalCols();
@@ -1015,7 +1135,8 @@ class DiagramSupport {
                 focusRouteIds, false)
                 .withAiDescriptions(aiDescriptions(), Theme.aiAssisted())
                 .withNodeLines(nodeLines)
-                .withGroups(groupTags, groupColors);
+                .withGroups(groupTags, groupColors)
+                .withErrorLayer(layoutShowErrorPaths ? errorLayer : null);
 
         List<Rect> vChunks = Layout.vertical()
                 .constraints(Constraint.fill(), Constraint.length(1))
@@ -1938,7 +2059,8 @@ class DiagramSupport {
                 false, showDescription, historyRouteIds, historyFailed)
                 .withAiDescriptions(aiDescriptions(), Theme.aiAssisted())
                 .withNodeLines(nodeLines)
-                .withGroups(groupTags, groupColors);
+                .withGroups(groupTags, groupColors)
+                .withErrorLayer(layoutShowErrorPaths ? errorLayer : null);
 
         int totalRows = widget.getTotalRows();
         int totalCols = widget.getTotalCols();
@@ -1957,7 +2079,8 @@ class DiagramSupport {
                 false, showDescription, historyRouteIds, historyFailed)
                 .withAiDescriptions(aiDescriptions(), Theme.aiAssisted())
                 .withNodeLines(nodeLines)
-                .withGroups(groupTags, groupColors);
+                .withGroups(groupTags, groupColors)
+                .withErrorLayer(layoutShowErrorPaths ? errorLayer : null);
 
         List<Rect> vChunks = Layout.vertical()
                 .constraints(Constraint.fill(), Constraint.length(1))
@@ -2138,9 +2261,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,6 +2302,21 @@ 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;
             }
@@ -2205,9 +2369,16 @@ 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;
+            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..442eaa58354c 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,12 @@ 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 (topologyMode && diagram.isShowDiagram() && 
ke.isCharIgnoreCase('x')) {
+            diagram.setShowErrorPaths(!diagram.isShowErrorPaths());
+            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 +725,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 +748,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",
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..9fc28da64b90
--- /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,55 @@
+/*
+ * 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
+ * @param channelsY the layout y of the first channel row, one row per route 
of the frame that a route above sends to
+ */
+public record ErrorLayer(int frameTopY, Set<String> routeIds, List<ErrorPath> 
paths, int channelsY) {
+
+    /**
+     * 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/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..c3efc3ef7763 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,10 @@ 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;
+    // the gutter on the left the error paths go down, when the error layer is 
shown
+    private int colOffset;
+    private static final int GUTTER = 3;
 
     public record NodeBox(String routeId, int startRow, int endRow, int 
startCol, int endCol, int layer) {
     }
@@ -163,7 +167,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 +236,16 @@ 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, the dashed
+     * error paths into them (down a gutter on the left, clear of the routes), 
and where each one's failures come from.
+     */
+    public TopologyDiagramWidget withErrorLayer(ErrorLayer layer) {
+        this.errorLayer = layer;
+        this.colOffset = layer != null ? GUTTER : 0;
+        return this;
+    }
+
     public List<NodeBox> getNodeBoxes() {
         return nodeBoxes;
     }
@@ -239,6 +254,11 @@ public class TopologyDiagramWidget implements Widget {
     public void render(Rect area, Buffer buffer) {
         nodeBoxes.clear();
 
+        if (errorLayer != null) {
+            drawErrorFrame(buffer, area);
+            drawErrorPaths(buffer, area);
+        }
+
         for (TopologyLayoutEdge edge : layout.edges) {
             if (!edge.selfLoop) {
                 drawEdge(buffer, area, edge);
@@ -254,6 +274,148 @@ public class TopologyDiagramWidget implements Widget {
         for (TopologyLayoutNode node : layout.nodes) {
             drawNode(buffer, area, node);
         }
+
+        if (errorLayer != null) {
+            drawErrorLabels(buffer, area);
+        }
+    }
+
+    private Style errorStyle() {
+        return Theme.warning();
+    }
+
+    private boolean inFrame(TopologyLayoutNode node) {
+        return errorLayer != null && node.routeId != null && 
errorLayer.routeIds().contains(node.routeId);
+    }
+
+    private TopologyLayoutNode nodeOf(String routeId) {
+        for (TopologyLayoutNode n : layout.nodes) {
+            if (routeId != null && routeId.equals(n.routeId)) {
+                return n;
+            }
+        }
+        return null;
+    }
+
+    /** 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 error paths from the routes above into the routes of the frame: out 
of the left side of the route, down the
+     * gutter, along the channel row of the target, and down into it. A 
route's own failures coming back to it are not
+     * drawn as a loop; its label says so.
+     */
+    private void drawErrorPaths(Buffer buffer, Rect area) {
+        Style s = errorStyle();
+        int gutter = 1;
+        List<String> targets = new ArrayList<>();
+        for (ErrorLayer.ErrorPath p : errorLayer.paths()) {
+            if (errorLayer.routeIds().contains(p.toRouteId()) && 
!errorLayer.routeIds().contains(p.fromRouteId())
+                    && !targets.contains(p.toRouteId())) {
+                targets.add(p.toRouteId());
+            }
+        }
+        java.util.Set<String> drawn = new java.util.HashSet<>();
+        for (ErrorLayer.ErrorPath p : errorLayer.paths()) {
+            int t = targets.indexOf(p.toRouteId());
+            TopologyLayoutNode from = nodeOf(p.fromRouteId());
+            TopologyLayoutNode to = nodeOf(p.toRouteId());
+            if (t < 0 || from == null || to == null || inFrame(from)
+                    || !drawn.add(p.fromRouteId() + ">" + p.toRouteId())) {
+                continue;
+            }
+            int srcRow = toRow(from.y) + 1;
+            int srcCol = toCol(from.x) - 1;
+            int channelRow = toRow(errorLayer.channelsY()) + t;
+            int toCx = toCol(to.x + to.width / 2);
+            int toTop = toRow(to.y);
+            for (int c = gutter + 1; c <= srcCol; c++) {
+                plotLine(buffer, area, srcRow, c, DASH_H, s);
+            }
+            setChar(buffer, area, srcRow, gutter, TL, s);
+            for (int r = srcRow + 1; r < channelRow; r++) {
+                plotLine(buffer, area, r, gutter, DASH_V, s);
+            }
+            setChar(buffer, area, channelRow, gutter, BL, s);
+            for (int c = gutter + 1; c < toCx; c++) {
+                plotLine(buffer, area, channelRow, c, DASH_H, s);
+            }
+            setChar(buffer, area, channelRow, toCx, TR, s);
+            for (int r = channelRow + 1; r < toTop - 1; r++) {
+                plotLine(buffer, area, r, toCx, DASH_V, s);
+            }
+            setChar(buffer, area, toTop - 1, toCx, ARROW, s);
+        }
+    }
+
+    /**
+     * 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() {
+        Map<TopologyLayoutNode, List<String>> answer = new 
java.util.LinkedHashMap<>();
+        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 +423,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) {
@@ -500,7 +662,7 @@ public class TopologyDiagramWidget implements Widget {
         if (nodeWidth == 0) {
             return 0;
         }
-        return pixelX * boxWidth / nodeWidth;
+        return pixelX * boxWidth / nodeWidth + colOffset;
     }
 
     private int toRow(int pixelY) {
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..55a4dc41e9a4 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
@@ -159,6 +160,9 @@ 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 the error paths into them
+  and how each failure is handled (needs Camel 4.23+ in the integration)
 - `s` — open the integration summary (camel-summary.md)
 - `Esc` — close diagram
 
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..0b308df6a874
--- /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,130 @@
+/*
+ * 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.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 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");
+    }
+
+    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