You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/10/08 09:34:18 UTC
(camel) 01/01: CAMEL-25429: camel-jbang - the monitor draws the error handling in a frame of its own
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 <cl...@gmail.com>
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) <no...@anthropic.com>
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;
+ }
+}