This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-26352-diagram-problems in repository https://gitbox.apache.org/repos/asf/camel.git
commit ff63344232d807644e9f5efa449a52448351b238 Author: Claus Ibsen <[email protected]> AuthorDate: Tue Oct 27 17:41:26 2026 +0100 CAMEL-26352: camel-diagram - mark the steps with a problem in a route diagram image, and say what it is camel cmd route-diagram of source files checks the routes and marks the steps with a problem: a warning border and a badge on the card, and the problem in a callout beside the route, at the height of the step, pointed at its card. In a route with branches a callout beside the route would cross the steps of another branch, so the badge has a number and the problems are a numbered list below the route, in the order of the steps. A YAML route is checked as camel validate does (SourceValidator: the schema, the endpoints and expressions against the catalog, the body flow). A Java or XML route with the checks that read the source without compiling it (RouteAssist): the route diagram reads Java routes without compiling them, and the Java checks of camel validate compile the source. A problem goes on the step whose line is the last one at or before it. --problems turns them on and off, on by default. The camel_render_route_diagram MCP tool marks them too. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../org/apache/camel/catalog/docs/diagram.adoc | 15 +++ .../camel-diagram/src/main/docs/diagram.adoc | 15 +++ .../camel/diagram/RouteDiagramLayoutEngine.java | 126 +++++++++++++++++- .../apache/camel/diagram/RouteDiagramRenderer.java | 76 +++++++++++ .../camel/diagram/RouteDiagramCalloutTest.java | 142 ++++++++++++++++++++ .../modules/ROOT/images/route-diagram-problems.png | Bin 0 -> 73746 bytes .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 5 + .../camel-jbang-cmd-route-diagram.adoc | 1 + .../META-INF/camel-jbang-commands-metadata.json | 2 +- .../commands/action/CamelRouteDiagramAction.java | 10 ++ .../core/commands/action/RouteDiagramProblems.java | 146 +++++++++++++++++++++ .../commands/action/RouteDiagramProblemsTest.java | 146 +++++++++++++++++++++ .../jbang/core/commands/mcp/RouteDiagramTools.java | 2 + 13 files changed, 684 insertions(+), 2 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/diagram.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/diagram.adoc index 1f982fa19134..8741d8704d6f 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/diagram.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/diagram.adoc @@ -201,6 +201,21 @@ camel cmd route-diagram myapp --output=myapp.png --body-types=false A diagram of a source file has no body types, as its routes have not run. +=== Problems of the Steps + +A diagram of source files marks the steps that the checks of `camel validate` find a problem with: the card has a +yellow border and a badge, and the problem is in a callout beside the route, at the height of the step. For example a +Groovy step that reads fields of the file as it was read, before it is unmarshalled: + +image::ROOT:route-diagram-problems.png[The orders route: the setBody step reads body.items of the file as it was read, and a callout beside it says to unmarshal it first] + +In a route with branches (`choice`, `multicast`, `doTry`, ...) a callout beside the route would cross the steps of +another branch, so the badge has a number, and the problems are a numbered list below the route. + +A YAML route is checked as `camel validate` does: the YAML DSL schema, the endpoints and expressions against the +catalog, and the body flow. A Java or XML route is checked with the checks that read the source without compiling it, +as the route diagram reads Java routes without compiling them. Turn the problems off with `--problems=false`. + == ASCII Art Rendering The ASCII art renderer produces plain text diagrams using box-drawing characters. diff --git a/components/camel-diagram/src/main/docs/diagram.adoc b/components/camel-diagram/src/main/docs/diagram.adoc index 1f982fa19134..8741d8704d6f 100644 --- a/components/camel-diagram/src/main/docs/diagram.adoc +++ b/components/camel-diagram/src/main/docs/diagram.adoc @@ -201,6 +201,21 @@ camel cmd route-diagram myapp --output=myapp.png --body-types=false A diagram of a source file has no body types, as its routes have not run. +=== Problems of the Steps + +A diagram of source files marks the steps that the checks of `camel validate` find a problem with: the card has a +yellow border and a badge, and the problem is in a callout beside the route, at the height of the step. For example a +Groovy step that reads fields of the file as it was read, before it is unmarshalled: + +image::ROOT:route-diagram-problems.png[The orders route: the setBody step reads body.items of the file as it was read, and a callout beside it says to unmarshal it first] + +In a route with branches (`choice`, `multicast`, `doTry`, ...) a callout beside the route would cross the steps of +another branch, so the badge has a number, and the problems are a numbered list below the route. + +A YAML route is checked as `camel validate` does: the YAML DSL schema, the endpoints and expressions against the +catalog, and the body flow. A Java or XML route is checked with the checks that read the source without compiling it, +as the route diagram reads Java routes without compiling them. Turn the problems off with `--problems=false`. + == ASCII Art Rendering The ASCII art renderer produces plain text diagrams using box-drawing characters. diff --git a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java index e4366bb9a81b..13a7937b1fba 100644 --- a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java +++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java @@ -56,6 +56,14 @@ public class RouteDiagramLayoutEngine { * the counters of the metrics. */ public static final int BODY_TYPES_GAP = 30 * SCALE; + /** The space between a route and the callouts of its problems. */ + public static final int CALLOUT_GAP = 16 * SCALE; + /** The space around the text of a callout. */ + public static final int CALLOUT_PAD = 8 * SCALE; + /** The space before the text of an entry of the list of problems: room for its number. */ + public static final int PROBLEM_NUMBER_WIDTH = 26 * SCALE; + // the end of a problem is often what to do about it, so a callout has room for most of it + private static final int MAX_CALLOUT_LINES = 6; private static final Pattern CARD_EXPRESSION = Pattern.compile("^([A-Za-z][\\w-]*)\\{(.*)\\}$", Pattern.DOTALL); public enum NodeLabelMode { @@ -74,6 +82,7 @@ public class RouteDiagramLayoutEngine { private boolean tableLayout; private boolean cardStyle; private boolean bodyTypes; + private boolean problems; // the space between the steps of the route being laid out private int stepGap = V_GAP; @@ -183,6 +192,24 @@ public class RouteDiagramLayoutEngine { return bodyTypes; } + /** + * Places the problems of the steps ({@link NodeInfo#problems}): in a callout beside the route, at the height of the + * step, or in a route with branches (where a callout would cross the steps of another branch) in a numbered list + * below the steps. Off by default. + */ + public void setProblems(boolean problems) { + this.problems = problems; + } + + public boolean isProblems() { + return problems; + } + + /** The width of a callout of the problems of a step. */ + public static int calloutWidth(int nodeWidth) { + return nodeWidth * 3 / 2; + } + /** * The widest a label of body types can be: wider than a step, as the steps of two branches are one and a half steps * apart, and still apart from the label of a step beside it. @@ -255,6 +282,11 @@ public class RouteDiagramLayoutEngine { * the class names, at most a few. */ public List<String> bodyTypes = List.of(); + /** + * The problems of the step, as the checks of the source report them, such as that a groovy step reads fields of + * the file as it was read: drawn in the diagram when it shows problems ({@link #setProblems}). + */ + public List<String> problems = List.of(); /** * The kinds of those bodies: null, payload, data and collection. More than one is a step that needs attention, * as a step that writes the body fails for the data, and one that reads fields fails for the payload. @@ -313,6 +345,26 @@ public class RouteDiagramLayoutEngine { public RouteErrorFrame errorFrame; /** Whether the arrows have room for the body types of the steps ({@link #setBodyTypes}). */ public boolean bodyTypes; + /** The problems of the steps, in callouts beside the route or in a list below it ({@link #setProblems}). */ + public final List<Callout> callouts = new ArrayList<>(); + /** + * Whether the problems are a numbered list below the steps (a route with branches), not callouts beside them. + */ + public boolean problemList; + } + + /** The problems of a step: a callout beside the route, or an entry of the list below it. */ + public static class Callout { + /** The step the problems are of. */ + public LayoutNode node; + /** The number of the entry in the list of problems, from 1; 0 for a callout. */ + public int number; + public int x; + public int y; + public int width; + public int height; + /** The text, wrapped to the width (the last line may need to be cut with an ellipsis). */ + public List<String> lines; } public static TreeNode buildTree(List<NodeInfo> nodes) { @@ -368,12 +420,16 @@ public class RouteDiagramLayoutEngine { } private List<String> wrapLabel(String label, int maxTextWidth) { + return wrapLabel(label, maxTextWidth, MAX_WRAP_LINES); + } + + private List<String> wrapLabel(String label, int maxTextWidth, int maxLines) { if (fontMetrics.stringWidth(label) <= maxTextWidth) { return List.of(label); } List<String> lines = new ArrayList<>(); String remaining = label; - while (!remaining.isEmpty() && lines.size() < MAX_WRAP_LINES) { + while (!remaining.isEmpty() && lines.size() < maxLines) { if (fontMetrics.stringWidth(remaining) <= maxTextWidth) { lines.add(remaining); remaining = ""; @@ -564,9 +620,77 @@ public class RouteDiagramLayoutEngine { lr.maxX = extent.maxX + overhang; lr.maxY = Math.max(lr.maxY, extent.maxY); + if (problems) { + layoutProblems(lr); + } return lr; } + private void layoutProblems(LayoutRoute lr) { + // in the order of the steps in the route (the order of the source) + List<LayoutNode> withProblems = lr.nodes.stream() + .filter(ln -> !ln.tableRow && ln.treeNode != null && !ln.treeNode.info.problems.isEmpty()) + .toList(); + if (withProblems.isEmpty()) { + return; + } + int lineHeight = fontMetrics.getHeight(); + lr.problemList = lr.nodes.stream().anyMatch(ln -> isBranchingEip(ln.type)); + if (!lr.problemList) { + // a column of callouts beside the route, each at the height of its step, below the one above it + int x = lr.maxX + CALLOUT_GAP; + int width = calloutWidth(nodeWidth); + int bottom = Integer.MIN_VALUE; + // without branches the order of the steps is from the top down + for (LayoutNode ln : withProblems) { + Callout c = new Callout(); + c.node = ln; + c.lines = problemLines(ln.treeNode.info.problems, width - 2 * CALLOUT_PAD); + c.x = x; + c.width = width; + c.height = c.lines.size() * lineHeight + 2 * CALLOUT_PAD; + c.y = Math.max(ln.y + ln.height / 2 - c.height / 2, bottom + CALLOUT_PAD); + bottom = c.y + c.height; + lr.callouts.add(c); + lr.maxY = Math.max(lr.maxY, bottom); + } + lr.maxX = x + width; + } else { + // a numbered list below the steps, as wide as the route + int width = Math.max(lr.maxX - PADDING, nodeWidth * 2); + int y = lr.maxY + V_GAP / 2; + int number = 0; + for (LayoutNode ln : withProblems) { + Callout c = new Callout(); + c.node = ln; + c.number = ++number; + c.lines = problemLines(ln.treeNode.info.problems, width - PROBLEM_NUMBER_WIDTH); + c.x = PADDING; + c.y = y; + c.width = width; + c.height = c.lines.size() * lineHeight; + y += c.height + CALLOUT_PAD; + lr.callouts.add(c); + } + lr.maxY = y; + lr.maxX = Math.max(lr.maxX, PADDING + width); + } + } + + /** The problems wrapped to the width, at most six lines: the renderer cuts the last one with an ellipsis. */ + private List<String> problemLines(List<String> problems, int width) { + List<String> lines = new ArrayList<>(); + for (String problem : problems) { + lines.addAll(wrapLabel(problem, width, MAX_CALLOUT_LINES)); + } + if (lines.size() > MAX_CALLOUT_LINES) { + String rest = String.join(" ", lines.subList(MAX_CALLOUT_LINES - 1, lines.size())); + lines = new ArrayList<>(lines.subList(0, MAX_CALLOUT_LINES - 1)); + lines.add(rest); + } + return lines; + } + private static void markTables(TreeNode node) { node.table = TABLE_EIPS.contains(node.info.type) && !node.children.isEmpty(); for (TreeNode child : node.children) { diff --git a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java index 2ba979f50cbe..9332fd7f99a0 100644 --- a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java +++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java @@ -142,6 +142,7 @@ public class RouteDiagramRenderer { private Color warning; private Color warningBg; private Color warningText; + private Color badgeText; /** * The colors of a theme: a preset (dark, light, transparent) or a spec of {@code key=color} pairs separated by @@ -185,6 +186,7 @@ public class RouteDiagramRenderer { c.warning = parseColor(map.getOrDefault("warning", "#d29922")); c.warningBg = parseColor(map.getOrDefault("warningBg", "#272115")); c.warningText = parseColor(map.getOrDefault("warningText", "#e3b341")); + c.badgeText = parseColor(map.getOrDefault("badgeText", "#1f2328")); return c; } @@ -289,6 +291,10 @@ public class RouteDiagramRenderer { public Color getWarningText() { return warningText; } + + public Color getBadgeText() { + return badgeText; + } } public BufferedImage renderDiagram( @@ -471,6 +477,76 @@ public class RouteDiagramRenderer { for (LayoutNode ln : lr.nodes) { drawNode(g, ln, colors, ln == lastHighlightedNode); } + + for (RouteDiagramLayoutEngine.Callout c : lr.callouts) { + drawProblems(g, c, colors, lr.problemList, c.node == lastHighlightedNode); + } + } + + /** + * The problems of a step ({@link RouteDiagramLayoutEngine#setProblems}): a warning border and a badge on its card, + * and their text in a callout beside the route, pointed at the card, or in an entry of the numbered list below the + * route, with the number of the badge. + */ + private void drawProblems( + Graphics2D g, RouteDiagramLayoutEngine.Callout c, DiagramColors colors, boolean list, + boolean highlightFail) { + LayoutNode node = c.node; + if (!highlightFail) { + g.setColor(colors.getWarning()); + g.setStroke(new BasicStroke(HIGHLIGHT_STROKE_WIDTH)); + g.drawRoundRect(node.x, node.y, nodeWidth, node.height, ARC, ARC); + } + String mark = list ? Integer.toString(c.number) : "!"; + drawBadge(g, node.x + nodeWidth - 2 * SCALE, node.y + 2 * SCALE, mark, colors); + + Font font = new Font("SansSerif", Font.PLAIN, fontSizeNode); + FontMetrics fm = g.getFontMetrics(font); + int pad = RouteDiagramLayoutEngine.CALLOUT_PAD; + int textX; + int textWidth; + int baseline; + if (list) { + drawBadge(g, c.x + RouteDiagramLayoutEngine.PROBLEM_NUMBER_WIDTH / 2, c.y + fm.getHeight() / 2, mark, colors); + textX = c.x + RouteDiagramLayoutEngine.PROBLEM_NUMBER_WIDTH; + textWidth = c.width - RouteDiagramLayoutEngine.PROBLEM_NUMBER_WIDTH; + baseline = c.y + fm.getAscent(); + g.setColor(colors.getRouteLabel()); + } else { + // the pointer from the card to its callout + int cardY = node.y + node.height / 2; + int pointY = Math.max(c.y + pad, Math.min(cardY, c.y + c.height - pad)); + g.setColor(colors.getWarning()); + g.setStroke(new BasicStroke(STROKE_WIDTH)); + g.drawLine(node.x + nodeWidth, cardY, c.x, pointY); + g.setColor(colors.getWarningBg()); + g.fillRoundRect(c.x, c.y, c.width, c.height, ARC, ARC); + g.setColor(colors.getWarning()); + g.setStroke(new BasicStroke(BORDER_STROKE_WIDTH)); + g.drawRoundRect(c.x, c.y, c.width, c.height, ARC, ARC); + textX = c.x + pad; + textWidth = c.width - 2 * pad; + baseline = c.y + pad + fm.getAscent(); + g.setColor(colors.getWarningText()); + } + g.setFont(font); + for (String line : c.lines) { + g.drawString(fit(fm, line, textWidth), textX, baseline); + baseline += fm.getHeight(); + } + g.setStroke(new BasicStroke(STROKE_WIDTH)); + } + + /** A badge of a problem: a circle of the warning color with a mark ("!" or the number of the problem). */ + private void drawBadge(Graphics2D g, int cx, int cy, String mark, DiagramColors colors) { + int d = 18 * SCALE; + g.setColor(colors.getWarning()); + g.fillOval(cx - d / 2, cy - d / 2, d, d); + Font font = new Font("SansSerif", Font.BOLD, fontSizeNode - SCALE); + FontMetrics fm = g.getFontMetrics(font); + g.setFont(font); + g.setColor(colors.getBadgeText()); + g.drawString(mark, cx - fm.stringWidth(mark) / 2, cy - fm.getHeight() / 2 + fm.getAscent()); } private void drawRoute(Graphics2D g, LayoutRoute lr, DiagramColors colors) { diff --git a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramCalloutTest.java b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramCalloutTest.java new file mode 100644 index 000000000000..77bd6e7dc57b --- /dev/null +++ b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramCalloutTest.java @@ -0,0 +1,142 @@ +/* + * 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.diagram; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.util.List; + +import org.apache.camel.diagram.RouteDiagramLayoutEngine.Callout; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutRoute; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeInfo; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.RouteInfo; +import org.apache.camel.diagram.RouteDiagramRenderer.DiagramColors; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The problems of the steps in a route diagram image: callouts beside a route, a numbered list below a route with + * branches. + */ +class RouteDiagramCalloutTest { + + private static final String GROOVY = "groovy reads fields of the body (body.items), but the body here is the file as" + + " it was read (a GenericFile), not parsed data - unmarshal it first"; + + private static NodeInfo node(String type, String code, int level, String... problems) { + NodeInfo n = new NodeInfo(); + n.type = type; + n.id = type + level + code.length(); + n.code = code; + n.level = level; + n.problems = List.of(problems); + return n; + } + + private static RouteInfo orders() { + RouteInfo r = new RouteInfo(); + r.routeId = "orders"; + r.nodes.add(node("route", "route[orders]", 0)); + r.nodes.add(node("from", "from[file:orders]", 1)); + r.nodes.add(node("setBody", "setBody[groovy{body.items}]", 2, GROOVY)); + r.nodes.add(node("to", "to[http:pricing]", 2, "the http producer cannot send a Map")); + r.nodes.add(node("to", "to[jms:priced]", 2)); + return r; + } + + private static RouteInfo choice() { + RouteInfo r = new RouteInfo(); + r.routeId = "choice"; + r.nodes.add(node("route", "route[choice]", 0)); + r.nodes.add(node("from", "from[timer:tick]", 1)); + r.nodes.add(node("choice", "choice", 2)); + r.nodes.add(node("when", "when[simple{${header.kind} == 0}]", 3)); + r.nodes.add(node("to", "to[kafka:zero?brokerz=localhost]", 4, "kafka has no option brokerz")); + r.nodes.add(node("otherwise", "otherwise", 3)); + r.nodes.add(node("marshal", "marshal[jackson]", 4, "the body is a String here")); + return r; + } + + private static RouteDiagramLayoutEngine engine(boolean problems) { + RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(); + engine.setCardStyle(true); + engine.setProblems(problems); + return engine; + } + + @Test + void calloutsBesideARoute() { + RouteDiagramLayoutEngine engine = engine(true); + LayoutRoute lr = engine.layoutRoute(orders(), RouteDiagramLayoutEngine.PADDING); + + assertThat(lr.problemList).isFalse(); + assertThat(lr.callouts).hasSize(2); + Callout groovy = lr.callouts.get(0); + Callout http = lr.callouts.get(1); + assertThat(groovy.node.type).isEqualTo("setBody"); + assertThat(groovy.number).isZero(); + // right of the steps, the text wrapped to more than one line + int right = groovy.node.x + engine.getNodeWidth(); + assertThat(groovy.x).isEqualTo(right + RouteDiagramLayoutEngine.CALLOUT_GAP); + assertThat(groovy.lines).hasSizeGreaterThan(1); + assertThat(String.join(" ", groovy.lines)).contains("GenericFile"); + // one below the other, and room for them in the image + assertThat(http.y).isGreaterThanOrEqualTo(groovy.y + groovy.height); + assertThat(lr.maxX).isEqualTo(groovy.x + groovy.width); + + // without problems shown, the route does not change + LayoutRoute plain = engine(false).layoutRoute(orders(), RouteDiagramLayoutEngine.PADDING); + assertThat(plain.callouts).isEmpty(); + assertThat(plain.maxX).isLessThan(lr.maxX); + } + + @Test + void numberedListBelowARouteWithBranches() { + RouteDiagramLayoutEngine engine = engine(true); + LayoutRoute plain = engine(false).layoutRoute(choice(), RouteDiagramLayoutEngine.PADDING); + LayoutRoute lr = engine.layoutRoute(choice(), RouteDiagramLayoutEngine.PADDING); + + assertThat(lr.problemList).isTrue(); + assertThat(lr.callouts).hasSize(2); + // numbered in the order of the steps in the route, below the steps + assertThat(lr.callouts.get(0).number).isEqualTo(1); + assertThat(lr.callouts.get(0).node.type).isEqualTo("to"); + assertThat(lr.callouts.get(1).number).isEqualTo(2); + assertThat(lr.callouts.get(1).node.type).isEqualTo("marshal"); + assertThat(lr.callouts.get(0).y).isGreaterThan(plain.maxY); + assertThat(lr.maxY).isGreaterThan(plain.maxY); + } + + @Test + void drawTheProblems() { + DiagramColors colors = DiagramColors.parse("light"); + RouteDiagramLayoutEngine engine = engine(true); + LayoutRoute lr = engine.layoutRoute(orders(), RouteDiagramLayoutEngine.PADDING); + BufferedImage image = new RouteDiagramRenderer().renderDiagram(List.of(lr), lr.maxY, colors); + + Callout groovy = lr.callouts.get(0); + // the callout is filled with the warning background, at its right end where there is no text + assertThat(new Color( + image.getRGB(groovy.x + groovy.width - 4 * RouteDiagramLayoutEngine.SCALE, + groovy.y + groovy.height / 2))) + .isEqualTo(colors.getWarningBg()); + // the card of the step has a warning border + assertThat(new Color(image.getRGB(groovy.node.x + 1, groovy.node.y + groovy.node.height / 2))) + .isEqualTo(colors.getWarning()); + } +} diff --git a/docs/components/modules/ROOT/images/route-diagram-problems.png b/docs/components/modules/ROOT/images/route-diagram-problems.png new file mode 100644 index 000000000000..5a1b940cf1e8 Binary files /dev/null and b/docs/components/modules/ROOT/images/route-diagram-problems.png differ diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc index 8d3e41666cf5..bf4287b29414 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc @@ -2534,6 +2534,11 @@ color no longer applies to the steps of a route diagram (it still does to the to profile) shows them on the arrows, with longer arrows to hold them. Use `--body-types=false` for the diagram without them. +`camel cmd route-diagram` of source files, and the `camel_render_route_diagram` MCP tool, check the routes as +`camel validate` does, and mark the steps with a problem: a yellow border and a callout beside the route (a numbered +list below a route with branches). An image of routes with problems is wider or taller. Use `--problems=false` for the +diagram without them. + `camel cmd route-diagram --output=diagram.png` without `--theme` now saves the PNG image in the `light` theme. Before, the default theme was `unicode` also for a file, so the command saved the text diagram in `diagram.txt` instead. A diagram in the terminal is still text (`unicode`) by default, and a file that does not end with `.png` still gets the diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc index 6becab7fd9e1..55c31b82ebd6 100644 --- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc +++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc @@ -32,6 +32,7 @@ camel cmd route-diagram [options] | `--metric` | Whether to include live metrics (only possible for running Camel application) | true | boolean | `--node-label` | What text to display in diagram nodes: code, description, or both (default) | both | String | `--output` | Save diagram to a file (PNG for image themes, text for ascii theme) | | String +| `--problems` | Whether to mark the steps that the checks of camel validate find a problem with, and say what it is beside the route, in an image of source files | true | boolean | `--theme` | Color theme preset (dark, light, transparent, ascii, unicode) or custom colors (e.g. bg=#1e1e1e:from=#2e7d32:to=#1565c0). Values can be #hex or ANSI color names (e.g. from=seagreen:to=steelblue). Use bg= for transparent. Use ascii/unicode for plain text output. Can also be set via DIAGRAM_COLORS env var. Default: unicode in the terminal, and light when --output is a .png file. | | String | `--timeout` | How long to wait for the integration to answer, in millis or as a duration such as 30s | 10000 | long | `--watch` | Execute periodically and showing output fullscreen | | boolean diff --git a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json index 7baca55b3dfa..b85749921ba7 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json +++ b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json @@ -3,7 +3,7 @@ { "name": "ask", "fullName": "ask", "description": "Ask a question about a running Camel application using AI", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Ask", "options": [ { "names": "--api-key", "description": "API key. Also reads ANTHROPIC_API_KEY, OPENAI_API_KEY, WATSONX_APIKEY, or LLM_API_KEY env vars", "javaType": "java.lang.String", "type": "string" }, { "names": "--api-type", "description": "API type: 'ollama', 'openai', 'anthropic', or 'watsonx'", "javaType": [...] { "name": "bind", "fullName": "bind", "description": "DEPRECATED: Bind source and sink Kamelets as a new Camel integration", "deprecated": true, "sourceClass": "org.apache.camel.dsl.jbang.core.commands.bind.Bind", "options": [ { "names": "--error-handler", "description": "Add error handler (none|log|sink:<endpoint>). Sink endpoints are expected in the format [[apigroup\/]version:]kind:[namespace\/]name, plain Camel URIs or Kamelet name.", "javaType": "java.lang.String", "type": "stri [...] { "name": "catalog", "fullName": "catalog", "description": "List artifacts from Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.catalog.CatalogCommand", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "component", "fullName": "catalog component", "description": "List components from the Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.co [...] - { "name": "cmd", "fullName": "cmd", "description": "Performs commands in the running Camel integrations, such as start\/stop route, or change logging levels.", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "browse", "fullName": "cmd browse", "description": "Browse pending messages on endpoints [...] + { "name": "cmd", "fullName": "cmd", "description": "Performs commands in the running Camel integrations, such as start\/stop route, or change logging levels.", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "browse", "fullName": "cmd browse", "description": "Browse pending messages on endpoints [...] { "name": "completion", "fullName": "completion", "description": "Generate completion script for bash\/zsh", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Complete", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ] }, { "name": "config", "fullName": "config", "description": "Get and set user configuration values", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.config.ConfigCommand", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "get", "fullName": "config get", "description": "Display user configuration value", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.config. [...] { "name": "debug", "fullName": "debug", "description": "Debug local Camel integration", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Debug", "options": [ { "names": "--ago", "description": "Use ago instead of yyyy-MM-dd HH:mm:ss in timestamp.", "javaType": "boolean", "type": "boolean" }, { "names": "--background", "description": "Run in the background", "defaultValue": "false", "javaType": "boolean", "type": "boolean" }, { "names": "--background-wait", "description": "How [...] diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java index cc26597f9a75..732e23cb5ed7 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java @@ -131,6 +131,11 @@ public class CamelRouteDiagramAction extends ActionWatchCommand { + " history recorded them, such as in the dev profile)") boolean bodyTypes; + @CommandLine.Option(names = { "--problems" }, defaultValue = "true", arity = "0..1", fallbackValue = "true", + description = "Whether to mark the steps that the checks of camel validate find a problem with, and" + + " say what it is beside the route, in an image of source files") + boolean problems; + @CommandLine.Option(names = { "--highlight" }, description = "Comma-separated node IDs to highlight in the diagram") String highlight; @@ -229,6 +234,9 @@ public class CamelRouteDiagramAction extends ActionWatchCommand { if (routes == null) { return 1; } + if (problems && !isTextTheme()) { + RouteDiagramProblems.attach(routes, files); + } } else { // more than one match is an error that says which integrations match CamelProcess process = processes.size() > 1 ? ProcessFinder.requireOne(nameOrPid) : processes.get(0); @@ -283,6 +291,7 @@ public class CamelRouteDiagramAction extends ActionWatchCommand { // an image draws the steps as cards: a title and its detail beside an icon engine.setCardStyle(!isTextTheme()); engine.setBodyTypes(bodyTypes && !isTextTheme()); + engine.setProblems(problems && !isTextTheme()); List<LayoutRoute> layoutRoutes = new ArrayList<>(); int currentY = RouteDiagramLayoutEngine.PADDING; @@ -544,6 +553,7 @@ public class CamelRouteDiagramAction extends ActionWatchCommand { this.nodeLabel = nodeLabel; } this.metric = false; + this.problems = true; return doCall(); } diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramProblems.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramProblems.java new file mode 100644 index 000000000000..9814d782203c --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramProblems.java @@ -0,0 +1,146 @@ +/* + * 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.action; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.camel.catalog.CamelCatalog; +import org.apache.camel.catalog.DefaultCamelCatalog; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeInfo; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.RouteInfo; +import org.apache.camel.dsl.jbang.core.commands.ai.RouteAssist; +import org.apache.camel.dsl.jbang.core.commands.ai.SourceValidator; + +/** + * The problems of the source files of a route diagram, on the steps they are of: the checks of {@code camel validate} + * for a YAML route, and for Java and XML the checks that read the source without compiling it ({@link RouteAssist}), as + * the route diagram reads Java routes without compiling them. + */ +final class RouteDiagramProblems { + + /** A problem of a line of the source; line 0 for a problem of the file. */ + record Problem(int line, String text) { + } + + private static final Pattern MESSAGE_LINE = Pattern.compile("^Line (\\d+): (.*)$", Pattern.DOTALL); + // the step and route a message names first, which the diagram shows already: "setBody: route orders: " + private static final Pattern STEP_PREFIX = Pattern.compile("^[A-Za-z][\\w-]*: route [^:\\s]+: "); + + private RouteDiagramProblems() { + } + + /** + * Puts the problems of the source files on the steps of their routes ({@link NodeInfo#problems}): a problem on the + * step whose line is the last one before it (the line of the option or expression that is wrong is inside its + * step), a problem of the file on the first step. + */ + static void attach(List<RouteInfo> routes, List<String> files) { + CamelCatalog catalog = null; + for (String file : files) { + Path path = Path.of(file); + if (!Files.isRegularFile(path)) { + continue; + } + String name = path.getFileName().toString(); + List<NodeInfo> nodes = routes.stream() + .filter(r -> sourceOf(r, name)) + .flatMap(r -> r.nodes.stream()) + .toList(); + if (nodes.isEmpty()) { + continue; + } + String content; + try { + content = Files.readString(path); + } catch (IOException e) { + continue; + } + if (catalog == null) { + catalog = new DefaultCamelCatalog(); + } + for (Problem p : problems(name, content, catalog, path.toAbsolutePath().getParent())) { + NodeInfo node = stepAt(nodes, p.line()); + List<String> list = new ArrayList<>(node.problems); + list.add(p.text()); + node.problems = List.copyOf(list); + } + } + } + + /** The problems of a source file. */ + static List<Problem> problems(String name, String content, CamelCatalog catalog, Path directory) { + String lower = name.toLowerCase(Locale.ROOT); + List<Problem> answer = new ArrayList<>(); + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) { + for (String msg : SourceValidator.validate(name, content, catalog, null, directory)) { + answer.add(problem(msg)); + } + } else if (RouteAssist.supports(name, content)) { + for (RouteAssist.Diagnostic d : RouteAssist.diagnostics(name, content, catalog, directory)) { + if (d.severity() == RouteAssist.Severity.ERROR) { + answer.add(new Problem(d.line(), text(d.message()))); + } + } + } + return answer; + } + + /** A message of the validator, "Line 7: setBody: route orders: groovy reads ...", as a problem. */ + static Problem problem(String message) { + Matcher m = MESSAGE_LINE.matcher(message); + if (m.matches()) { + return new Problem(Integer.parseInt(m.group(1)), text(m.group(2))); + } + return new Problem(0, text(message)); + } + + /** + * The text of a problem for a callout: its first line (more lines are the source to write, which the diagram has no + * room for), without the step and route it names first, which the callout points at. + */ + static String text(String message) { + String text = message.strip(); + int nl = text.indexOf('\n'); + if (nl > 0) { + text = text.substring(0, nl).strip(); + } + return STEP_PREFIX.matcher(text).replaceFirst(""); + } + + /** The step of a line: the last step that starts at or before it, or the first step. */ + static NodeInfo stepAt(List<NodeInfo> nodes, int line) { + NodeInfo answer = nodes.get(0); + for (NodeInfo n : nodes) { + if (n.line > 0 && n.line <= line && n.line >= answer.line) { + answer = n; + } + } + return answer; + } + + /** Whether the route comes from the file: its source is the file name, or the class name of a Java file. */ + private static boolean sourceOf(RouteInfo route, String fileName) { + return route.source != null && (route.source.equals(fileName) || fileName.startsWith(route.source + ".")); + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramProblemsTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramProblemsTest.java new file mode 100644 index 000000000000..ce025867b27f --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramProblemsTest.java @@ -0,0 +1,146 @@ +/* + * 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.action; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeInfo; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.RouteInfo; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +class RouteDiagramProblemsTest { + + @TempDir + Path dir; + + private static NodeInfo node(String type, String code, int level, int line) { + NodeInfo n = new NodeInfo(); + n.type = type; + n.code = code; + n.level = level; + n.line = line; + return n; + } + + @Test + void textOfAProblem() { + // the step and route it names first are where the callout points + assertThat(RouteDiagramProblems.text("setBody: route orders: groovy reads fields of the body")) + .isEqualTo("groovy reads fields of the body"); + // the lines to write after it are left out + assertThat(RouteDiagramProblems.text("an option at the column of its EIP\n - to:\n uri: log:x")) + .isEqualTo("an option at the column of its EIP"); + assertThat(RouteDiagramProblems.problem("Line 7: setBody: route orders: groovy reads fields")) + .isEqualTo(new RouteDiagramProblems.Problem(7, "groovy reads fields")); + assertThat(RouteDiagramProblems.problem("the file is empty")) + .isEqualTo(new RouteDiagramProblems.Problem(0, "the file is empty")); + } + + @Test + void stepOfALine() { + List<NodeInfo> nodes = List.of(node("route", "route[orders]", 0, 4), node("from", "from[file:orders]", 1, 4), + node("setBody", "setBody[groovy{body.items}]", 2, 6), node("to", "to[http:pricing]", 2, 9)); + // the line of the expression is inside its step + assertThat(RouteDiagramProblems.stepAt(nodes, 7).type).isEqualTo("setBody"); + assertThat(RouteDiagramProblems.stepAt(nodes, 9).type).isEqualTo("to"); + assertThat(RouteDiagramProblems.stepAt(nodes, 4).type).isEqualTo("from"); + // before the first step, or of the file: the first step + assertThat(RouteDiagramProblems.stepAt(nodes, 1).type).isEqualTo("route"); + assertThat(RouteDiagramProblems.stepAt(nodes, 0).type).isEqualTo("route"); + } + + @Test + void problemsOfAYamlRoute() throws Exception { + Path file = dir.resolve("orders.camel.yaml"); + Files.writeString(file, """ + - route: + id: orders + from: + uri: file:orders + steps: + - setBody: + groovy: + expression: body.items + - to: + uri: log:orders + """); + RouteInfo route = new RouteInfo(); + route.routeId = "orders"; + route.source = "orders.camel.yaml"; + route.nodes.add(node("route", "route[orders]", 0, 4)); + route.nodes.add(node("from", "from[file:orders]", 1, 4)); + route.nodes.add(node("setBody", "setBody[groovy{body.items}]", 2, 6)); + route.nodes.add(node("to", "to[log:orders]", 2, 9)); + + RouteDiagramProblems.attach(List.of(route), List.of(file.toString())); + + // groovy reads fields of the file as it was read + assertThat(route.nodes.get(2).problems).singleElement().asString() + .contains("body.items").contains("GenericFile").doesNotStartWith("setBody: route"); + assertThat(route.nodes.get(0).problems).isEmpty(); + assertThat(route.nodes.get(3).problems).isEmpty(); + } + + @Test + void problemsOfAJavaRoute() throws Exception { + Path file = dir.resolve("MyRoute.java"); + Files.writeString(file, """ + import org.apache.camel.builder.RouteBuilder; + + public class MyRoute extends RouteBuilder { + @Override + public void configure() { + from("timer:tick") + .log("tick") + .to("kafka:orders?brokerz=localhost:9092"); + } + } + """); + RouteInfo route = new RouteInfo(); + route.routeId = "route1"; + route.source = "MyRoute"; + route.nodes.add(node("route", "route[route1]", 0, 6)); + route.nodes.add(node("from", "from[timer:tick]", 1, 6)); + route.nodes.add(node("log", "log[tick]", 2, 7)); + route.nodes.add(node("to", "to[kafka:orders?brokerz=localhost:9092]", 2, 8)); + + RouteDiagramProblems.attach(List.of(route), List.of(file.toString())); + + // the option the kafka component does not have, found without compiling the source + assertThat(route.nodes.get(3).problems).singleElement().asString().contains("brokerz"); + assertThat(route.nodes.get(2).problems).isEmpty(); + } + + @Test + void aFileWithoutRoutesOfTheDiagramIsNotChecked() throws Exception { + Path file = dir.resolve("other.camel.yaml"); + Files.writeString(file, "- route:\n from:\n uri: nope:\n"); + RouteInfo route = new RouteInfo(); + route.routeId = "orders"; + route.source = "orders.camel.yaml"; + route.nodes.add(node("route", "route[orders]", 0, 1)); + + RouteDiagramProblems.attach(List.of(route), List.of(file.toString(), dir.resolve("missing.yaml").toString())); + + assertThat(route.nodes.get(0).problems).isEmpty(); + } +} diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramTools.java b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramTools.java index 6c810a18bda2..4207f1d6eb9b 100644 --- a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramTools.java +++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramTools.java @@ -46,6 +46,8 @@ public class RouteDiagramTools { + "Java routes are read by the Java DSL parser, without compiling them; a step it cannot " + "work out (a lambda, a processor) is shown as a placeholder, and the parts it could not read " + "are listed in the message. " + + "An image marks the steps that the checks of camel validate find a problem with, and says " + + "what the problem is beside the route. " + "For image themes (light, dark, transparent) the PNG is written to disk and the absolute path " + "is returned. For text themes (ascii, unicode) the diagram is returned directly as text so " + "it can be read and understood by the caller. "
