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; + } +}
