This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch tui-diagram-reply-edges in repository https://gitbox.apache.org/repos/asf/camel.git
commit 4841b126c3c493b03317b2e2b9f23ed63de202da Author: Claus Ibsen <[email protected]> AuthorDate: Wed Nov 11 09:51:51 2026 +0100 CAMEL-26823: the Diagram and the source editor of the Camel CLI show whether a route waits for the answer of the route it sends to The topology of the Diagram ends an edge in ⇅ when the route waits for the answer (a direct call, a request-reply over a broker) and in ▼ when it hands the message off, from the reply of the runtime topology. A folded group waits when one of its routes is waited on. The jump hints of the source editor mark the route as the About view does: ↳ it waits, ⇢ a hand-off. From the sources: direct, kamelet, enrich and exchangePattern=InOut (in the uri or the parameters of the step) wait, a wire tap never does; the running integration decides when a tab has asked for its topology, so a route that consumes a request waits on a queue too. The jump links never ask the integration themselves. camel-diagram: TopologyEdgeInfo and TopologyLayoutEdge have reply; the six-argument TopologyLayoutEdge constructor is kept. The send rules are in SendHow and the jump link in a record of its own, as SourceTab and SourceViewer are at their size limits. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../org/apache/camel/diagram/TopologyHelper.java | 2 + .../apache/camel/diagram/TopologyLayoutEngine.java | 15 ++- .../camel/diagram/TopologyHelperErrorPathTest.java | 18 ++++ .../ROOT/pages/camel-jbang-tui-diagram.adoc | 4 + .../ROOT/pages/camel-jbang-tui-source-editor.adoc | 6 +- .../jbang/core/commands/tui/DiagramTopology.java | 10 ++ .../dsl/jbang/core/commands/tui/JumpLink.java | 43 ++++++++ .../camel/dsl/jbang/core/commands/tui/SendHow.java | 120 +++++++++++++++++++++ .../dsl/jbang/core/commands/tui/SourceTab.java | 62 +++++++---- .../dsl/jbang/core/commands/tui/SourceViewer.java | 13 +-- .../core/commands/tui/TopologyGroupFolds.java | 10 +- .../core/commands/tui/diagram/DiagramColors.java | 2 + .../tui/diagram/TopologyDiagramWidget.java | 6 +- .../src/main/resources/tui/help/diagram.md | 6 ++ .../src/main/resources/tui/help/source.md | 5 + .../commands/tui/SourceEditorProjectInfoTest.java | 2 +- .../jbang/core/commands/tui/SourceSizeTest.java | 2 +- .../core/commands/tui/SourceTabJumpLinksTest.java | 119 ++++++++++++++++++-- .../core/commands/tui/TopologyGroupFoldsTest.java | 15 +++ .../tui/diagram/TopologyReplyArrowTest.java | 89 +++++++++++++++ 20 files changed, 501 insertions(+), 48 deletions(-) diff --git a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyHelper.java b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyHelper.java index dc5f6ab09319..1b6a790d3b14 100644 --- a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyHelper.java +++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyHelper.java @@ -88,6 +88,8 @@ public final class TopologyHelper { edge.kind = eo.getStringOrDefault("kind", "call"); edge.via = eo.getString("via"); edge.handling = eo.getString("handling"); + // an integration before 4.24 does not say it: its edges are drawn as before + edge.reply = Boolean.TRUE.equals(eo.get("reply")); if (includeErrorPaths || !edge.isErrorPath()) { edges.add(edge); } diff --git a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyLayoutEngine.java b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyLayoutEngine.java index 1e5d657ead90..40f8bceb3adf 100644 --- a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyLayoutEngine.java +++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyLayoutEngine.java @@ -155,7 +155,7 @@ public class TopologyLayoutEngine { boolean backEdge = layers.getOrDefault(e.fromRouteId, 0) >= layers.getOrDefault(e.toRouteId, 0) && !e.fromRouteId.equals(e.toRouteId); boolean selfLoop = e.fromRouteId.equals(e.toRouteId); - layoutEdges.add(new TopologyLayoutEdge(from, to, e.endpoint, e.connectionType, backEdge, selfLoop)); + layoutEdges.add(new TopologyLayoutEdge(from, to, e.endpoint, e.connectionType, backEdge, selfLoop, e.reply)); } } @@ -338,6 +338,9 @@ public class TopologyLayoutEngine { public String via; // for an error path: handled, continued or notHandled (null when a predicate decides) public String handling; + // whether the source route waits for the answer of the target route (a direct call, a request-reply over a + // broker) rather than handing the message off + public boolean reply; public boolean isErrorPath() { return "error".equals(kind); @@ -382,17 +385,27 @@ public class TopologyLayoutEngine { public final String connectionType; public final boolean backEdge; public final boolean selfLoop; + // whether the source route waits for the answer of the target route + public final boolean reply; public TopologyLayoutEdge( TopologyLayoutNode from, TopologyLayoutNode to, String endpoint, String connectionType, boolean backEdge, boolean selfLoop) { + this(from, to, endpoint, connectionType, backEdge, selfLoop, false); + } + + public TopologyLayoutEdge( + TopologyLayoutNode from, TopologyLayoutNode to, + String endpoint, String connectionType, + boolean backEdge, boolean selfLoop, boolean reply) { this.from = from; this.to = to; this.endpoint = endpoint; this.connectionType = connectionType; this.backEdge = backEdge; this.selfLoop = selfLoop; + this.reply = reply; } } diff --git a/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyHelperErrorPathTest.java b/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyHelperErrorPathTest.java index d3e6f50d7ea2..5a06f235a196 100644 --- a/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyHelperErrorPathTest.java +++ b/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyHelperErrorPathTest.java @@ -52,6 +52,24 @@ class TopologyHelperErrorPathTest { .containsExactly("errorHandler/handled", "onException/notHandled"); } + @Test + void anEdgeSaysWhetherTheRouteWaitsForTheAnswer() { + // CAMEL-26823: reply is on every edge of a 4.24 integration; an older one does not say it + JsonObject root = new JsonObject(); + JsonArray edges = new JsonArray(); + JsonObject stock = edge("reserve-order", "stock-service", "call", null, null); + stock.put("reply", true); + edges.add(stock); + JsonObject shipping = edge("reserve-order", "shipping", "call", null, null); + shipping.put("reply", false); + edges.add(shipping); + edges.add(edge("legacy", "parked", null, null, null)); + root.put("edges", edges); + + assertThat(TopologyHelper.parseEdges(root)).extracting(e -> e.toRouteId + (e.reply ? " waits" : "")) + .containsExactly("stock-service waits", "shipping", "parked"); + } + private static JsonObject edge(String from, String to, String kind, String via, String handling) { JsonObject e = new JsonObject(); e.put("fromRouteId", from); 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 0a08066a53bb..493061bef0cd 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 @@ -31,6 +31,10 @@ The topology shows all routes as boxes, connected by arrows: * *Trigger routes* (timer, cron, etc.) appear at the top * *Downstream routes* appear below, connected by directed arrows +* The head of an arrow says whether the route waits for the answer of the route it sends to: `⇅` it does (a call to a + `direct` route, or a request-reply over a broker such as `exchangePattern=InOut` on a JMS queue), `▼` it hands the + message off (a queue, a topic, a `wireTap`). An integration before Camel 4.24 does not say it, and all its arrows end + in `▼` * Route boxes show the route ID, the `from` endpoint, and metrics when enabled Navigate between route boxes with arrow keys. When a route is selected, an *Info panel* appears diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc index 050c0e96b304..13fe2996ee62 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc @@ -315,7 +315,11 @@ image::jbang/camel-tui-source-xml-quick-doc.png[The documentation of the logging * *Jump links* -- a line that sends to another route, such as `to("seda:shipping")`, shows `↵ shipping`. Press *Enter* on the line to go to that route, also when it is in another file or another DSL. A `from` shows which route calls - it; when more than one does, it shows `↵ 2 callers`, and *Enter* opens a list of them to choose from. + it; when more than one does, it shows `↵ 2 callers`, and *Enter* opens a list of them to choose from. Before the + route, `↳` says that the route waits for the answer (`↵ ↳ audit` for a `direct` route, a request-reply with + `exchangePattern=InOut`, an `enrich`) and `⇢` that it hands the message off (`↵ ⇢ shipping` for a queue, a topic or a + `wireTap`), as in the About view. While the integration runs, and its Diagram, Routes or About view has been open, the + running routes decide: a route that consumes a request, from `platform-http`, also waits on a send to a queue. * *Go to route* -- *g* lists all the routes of the project; type to filter, and *Enter* opens the route. * *Bean jumps* -- a line that refers to a bean shows `↵ name`, and *Enter* goes to where the project declares it. * *Usages* -- *u* on a line with an endpoint lists where it is used: the routes that consume from it and the steps that diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTopology.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTopology.java index c97a69d92e81..47981e7978cd 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTopology.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTopology.java @@ -60,6 +60,16 @@ final class DiagramTopology { private volatile Structure structure; + /** + * The topology asked for last, when it is of this integration, without asking for it: null until a tab (Diagram, + * Routes, About) has asked for it. For what must not wait on the integration, such as the jump links of the source + * editor. + */ + JsonObject cached(String pid) { + Structure s = structure; + return s != null && pid != null && s.pid().equals(pid) ? s.topology() : null; + } + /** * The step of a route by its id, from the structure asked for last (the Routes tab shows what each step does); null * until the structure of the integration has been asked for, or when it has no such step. diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/JumpLink.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/JumpLink.java new file mode 100644 index 000000000000..e2e99b5e8b73 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/JumpLink.java @@ -0,0 +1,43 @@ +/* + * 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; + +/** + * A link on a line to where it leads; callersOf is the endpoint of a route sent to from more than one place, whose link + * opens a popup to choose one (CAMEL-25411), else null. how is how the send reaches the route: call when the sending + * route waits for the answer, async when it hands the message off, null when not a send between routes. + */ +record JumpLink(String routeId, String filePath, int targetLine, String callersOf, String how) { + + JumpLink(String routeId, String filePath, int targetLine) { + this(routeId, filePath, targetLine, null, null); + } + + JumpLink(String routeId, String filePath, int targetLine, String callersOf) { + this(routeId, filePath, targetLine, callersOf, null); + } + + JumpLink withHow(String how) { + return new JumpLink(routeId, filePath, targetLine, callersOf, how); + } + + /** The hint after the line: ↳ the route waits for the answer, ⇢ a hand-off, as the About view marks them. */ + String hint() { + String mark = "call".equals(how) ? "↳ " : "async".equals(how) ? "⇢ " : ""; + return " ↵ " + mark + routeId; + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendHow.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendHow.java new file mode 100644 index 000000000000..a970fe7f633d --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendHow.java @@ -0,0 +1,120 @@ +/* + * 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.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +import org.apache.camel.util.json.JsonObject; + +/** + * How a send of a route reaches the route that consumes it, for the jump links of the source editor: call when the + * sending route waits for the answer, async when it hands the message off (CAMEL-26823). + */ +final class SendHow { + + // a uri that asks for the answer of what it sends to, and the key of a step block that does + private static final Pattern IN_OUT_PARAMETER = Pattern.compile("[?&]exchangePattern=InOut(&|$)", Pattern.CASE_INSENSITIVE); + private static final Pattern IN_OUT_KEY + = Pattern.compile("(exchangePattern|pattern):\\s*[\"']?InOut[\"']?\\s*(#.*)?", Pattern.CASE_INSENSITIVE); + + private SendHow() { + } + + /** + * How a step reaches the route that consumes the endpoint it sends to, as the sources say it: call when the route + * waits for the answer (direct and kamelet run the route before it goes on, enrich waits for it, and + * exchangePattern=InOut asks for it, as a request-reply over a broker does), async when it hands the message off (a + * wire tap always does). null for a dead letter channel or a pollEnrich, which are no send the route waits on or + * hands off. A route that consumes an InOut message (from platform-http) waits on its sends too; only the running + * integration knows that (see {@link #how(String, String, String, Map)}). + */ + static String of(String keyword, String uri, boolean inOut) { + if (uri == null || "deadLetterChannel".equals(keyword) || "pollEnrich".equals(keyword)) { + return null; + } + if ("wireTap".equals(keyword)) { + return "async"; + } + int colon = uri.indexOf(':'); + String scheme = colon > 0 ? uri.substring(0, colon).toLowerCase(Locale.ROOT) : ""; + if ("enrich".equals(keyword) || "direct".equals(scheme) || "kamelet".equals(scheme) || inOut + || IN_OUT_PARAMETER.matcher(uri).find()) { + return "call"; + } + return "async"; + } + + /** Whether the block of a step (its parameters) asks for the answer: exchangePattern: InOut, or pattern: InOut. */ + static boolean blockSaysInOut(List<String> lines, int start, int indent) { + if (start < 0) { + return false; + } + for (int j = start + 1; j < lines.size(); j++) { + String line = lines.get(j); + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + if (SourceTab.lineIndent(line) <= indent) { + break; + } + if (IN_OUT_KEY.matcher(trimmed).matches()) { + return true; + } + } + return false; + } + + /** + * How the route reaches the other: as the running integration has it when its topology was asked for, else static. + */ + static String how(String fromRouteId, String toRouteId, String staticHow, Map<String, String> runtime) { + String known = fromRouteId != null ? runtime.get(fromRouteId + ">" + toRouteId) : null; + return known != null ? known : staticHow; + } + + /** + * How the routes of the selected integration reach each other, by "from>to": call when the route waits for the + * answer, else async. Only from the topology a tab has asked for already, so the jump links never wait on the + * integration; empty when there is none. + */ + static Map<String, String> runtime(MonitorContext ctx) { + IntegrationInfo info = ctx.findSelectedIntegration(); + JsonObject topology = info != null ? ctx.diagramTopology.cached(info.pid) : null; + Collection<?> edges = topology != null ? topology.getCollection("edges") : null; + if (edges == null) { + return Map.of(); + } + Map<String, String> answer = new HashMap<>(); + for (Object o : edges) { + if (o instanceof JsonObject e && !"error".equals(e.getString("kind"))) { + String from = e.getString("fromRouteId"); + String to = e.getString("toRouteId"); + if (from != null && to != null) { + answer.merge(from + ">" + to, Boolean.TRUE.equals(e.get("reply")) ? "call" : "async", + (a, b) -> "call".equals(a) || "call".equals(b) ? "call" : a); + } + } + } + return answer; + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java index a7e361624029..a506de36dc08 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java @@ -143,7 +143,8 @@ class SourceTab extends AbstractTab { record RouteEntry(String routeId, String fromUri, String filePath, int fromLine) { } - record ToEntry(String routeId, String toUri, String filePath, int toLine) { + /** A send of a route; how is how it reaches the route it sends to, see {@link SendHow#of}. */ + record ToEntry(String routeId, String toUri, String filePath, int toLine, String how) { } private List<RouteEntry> routeIndex = Collections.emptyList(); @@ -1357,9 +1358,11 @@ class SourceTab extends AbstractTab { emitRouteEntry(fromEntries, route.id(), JavaRouteScanner.uri(route.fromUri()), filePath, route.line()); String routeId = fromEntries.size() > before ? fromEntries.get(before).routeId() : ""; for (ScannedRoute.To to : route.tos()) { - String toUri = stripQueryParams(JavaRouteScanner.uri(to.uri())); + String uri = JavaRouteScanner.uri(to.uri()); + String toUri = stripQueryParams(uri); if (toUri != null && !toUri.isEmpty()) { - toEntries.add(new ToEntry(routeId, toUri, filePath, to.line())); + // the scanners do not say which step sends (a wire tap): the uri decides + toEntries.add(new ToEntry(routeId, toUri, filePath, to.line(), SendHow.of(null, uri, false))); } } } @@ -1420,6 +1423,8 @@ class SourceTab extends AbstractTab { int pendingFromLine = -1; boolean inLinkableBlock = false; int linkableBlockIndent = -1; + String blockKeyword = null; + int blockStart = -1; for (int i = 0; i < lines.size(); i++) { String line = lines.get(i); @@ -1491,14 +1496,18 @@ class SourceTab extends AbstractTab { ? trimmed.substring(prefix2.length()).trim() : trimmed.substring(prefix1.length()).trim(); if (!after.isEmpty() && !after.equals("{") && !after.startsWith("#")) { - String toUri = stripQueryParams(unquote(after)); + String uri = unquote(after); + String toUri = stripQueryParams(uri); if (toUri != null && !toUri.isEmpty()) { toEntries.add(new ToEntry( - currentRouteId != null ? currentRouteId : "", toUri, filePath, i)); + currentRouteId != null ? currentRouteId : "", toUri, filePath, i, + SendHow.of(kw, uri, false))); } } else { inLinkableBlock = true; linkableBlockIndent = indent; + blockKeyword = kw; + blockStart = i; } break; } @@ -1511,7 +1520,8 @@ class SourceTab extends AbstractTab { String toUri = stripQueryParams(val); if (toUri != null && !toUri.isEmpty()) { toEntries.add(new ToEntry( - currentRouteId != null ? currentRouteId : "", toUri, filePath, i)); + currentRouteId != null ? currentRouteId : "", toUri, filePath, i, + SendHow.of(blockKeyword, val, SendHow.blockSaysInOut(lines, blockStart, linkableBlockIndent)))); } } } @@ -1561,8 +1571,8 @@ class SourceTab extends AbstractTab { } /** The jump links of a file: to the routes its steps send to or are called from, and to the beans it refers to. */ - Map<Integer, SourceViewer.JumpLink> computeJumpLinks(Path currentFile) { - Map<Integer, SourceViewer.JumpLink> result = new LinkedHashMap<>(computeRouteJumpLinks(currentFile)); + Map<Integer, JumpLink> computeJumpLinks(Path currentFile) { + Map<Integer, JumpLink> result = new LinkedHashMap<>(computeRouteJumpLinks(currentFile)); ProjectBeans beans = projectBeans(); List<String> lines; try { @@ -1573,7 +1583,7 @@ class SourceTab extends AbstractTab { for (int i = 0; i < lines.size(); i++) { ProjectBeans.Location bean = beans.refOn(lines.get(i)); if (bean != null && !(bean.filePath().equals(currentFile.toString()) && bean.line() == i)) { - result.putIfAbsent(i, new SourceViewer.JumpLink(bean.label(), bean.filePath(), bean.line())); + result.putIfAbsent(i, new JumpLink(bean.label(), bean.filePath(), bean.line())); } } return result; @@ -1642,7 +1652,7 @@ class SourceTab extends AbstractTab { }; } - private Map<Integer, SourceViewer.JumpLink> computeRouteJumpLinks(Path currentFile) { + private Map<Integer, JumpLink> computeRouteJumpLinks(Path currentFile) { if (routeIndex.isEmpty()) { return Collections.emptyMap(); } @@ -1655,7 +1665,8 @@ class SourceTab extends AbstractTab { } String currentFilePath = currentFile.toString(); - Map<Integer, SourceViewer.JumpLink> result = new LinkedHashMap<>(); + Map<Integer, JumpLink> result = new LinkedHashMap<>(); + Map<String, String> runtime = SendHow.runtime(ctx); Map<String, RouteEntry> fromUriToRoute = new HashMap<>(); for (RouteEntry re : routeIndex) { @@ -1669,17 +1680,20 @@ class SourceTab extends AbstractTab { RouteEntry target = fromUriToRoute.get(te.toUri()); if (target != null && !target.routeId().equals(te.routeId())) { result.putIfAbsent(te.toLine(), - new SourceViewer.JumpLink(target.routeId(), target.filePath(), target.fromLine())); + new JumpLink(target.routeId(), target.filePath(), target.fromLine()) + .withHow(SendHow.how(te.routeId(), target.routeId(), te.how(), runtime))); } } } - addReverseLinks(currentFilePath, result); + addReverseLinks(currentFilePath, result, runtime); return result; } // forward links: to/toD/wireTap → target route's from boolean inLinkableBlock = false; int linkableBlockIndent = -1; + String blockKeyword = null; + int blockStart = -1; String currentRouteId = null; for (int i = 0; i < lines.size(); i++) { @@ -1710,6 +1724,7 @@ class SourceTab extends AbstractTab { } String uri = null; + String how = null; for (String kw : LINKABLE_KEYWORDS) { String prefix1 = kw + ":"; String prefix2 = "- " + kw + ":"; @@ -1719,9 +1734,12 @@ class SourceTab extends AbstractTab { : trimmed.substring(prefix1.length()).trim(); if (!after.isEmpty() && !after.equals("{") && !after.startsWith("#")) { uri = unquote(after); + how = SendHow.of(kw, uri, false); } else { inLinkableBlock = true; linkableBlockIndent = indent; + blockKeyword = kw; + blockStart = i; } break; } @@ -1731,6 +1749,7 @@ class SourceTab extends AbstractTab { String val = extractYamlValue(uriLine(trimmed), "uri"); if (val != null && !val.isEmpty()) { uri = val; + how = SendHow.of(blockKeyword, val, SendHow.blockSaysInOut(lines, blockStart, linkableBlockIndent)); } } @@ -1738,12 +1757,13 @@ class SourceTab extends AbstractTab { String baseUri = stripQueryParams(uri); RouteEntry target = fromUriToRoute.get(baseUri); if (target != null && !target.routeId().equals(currentRouteId)) { - result.put(i, new SourceViewer.JumpLink(target.routeId(), target.filePath(), target.fromLine())); + result.put(i, new JumpLink(target.routeId(), target.filePath(), target.fromLine()) + .withHow(SendHow.how(currentRouteId, target.routeId(), how, runtime))); } } } - addReverseLinks(currentFilePath, result); + addReverseLinks(currentFilePath, result, runtime); return result; } @@ -1751,7 +1771,8 @@ class SourceTab extends AbstractTab { * Reverse links: a from line to the route that sends to it (jumps to the caller's to line), or when several do, to * a popup to choose one of them: a Kamelet or a direct: route used by more than one route. */ - private void addReverseLinks(String currentFilePath, Map<Integer, SourceViewer.JumpLink> result) { + private void addReverseLinks( + String currentFilePath, Map<Integer, JumpLink> result, Map<String, String> runtime) { for (RouteEntry re : routeIndex) { if (!currentFilePath.equals(re.filePath())) { continue; @@ -1760,10 +1781,11 @@ class SourceTab extends AbstractTab { if (callers.size() == 1) { ToEntry te = callers.get(0); String callerRouteId = te.routeId().isEmpty() ? "route" : te.routeId(); - result.putIfAbsent(re.fromLine(), new SourceViewer.JumpLink(callerRouteId, te.filePath(), te.toLine())); + result.putIfAbsent(re.fromLine(), new JumpLink(callerRouteId, te.filePath(), te.toLine()) + .withHow(SendHow.how(te.routeId(), re.routeId(), te.how(), runtime))); } else if (callers.size() > 1) { result.putIfAbsent(re.fromLine(), - new SourceViewer.JumpLink(callers.size() + " callers", null, -1, re.fromUri())); + new JumpLink(callers.size() + " callers", null, -1, re.fromUri())); } } } @@ -1876,7 +1898,7 @@ class SourceTab extends AbstractTab { } } - private void handleJumpLink(SourceViewer.JumpLink link) { + private void handleJumpLink(JumpLink link) { if (link.callersOf() != null) { // sent to from more than one place: choose which gotoRoutePopup.openItems(callersOf(link.callersOf()), "Callers of " + link.callersOf()); @@ -1959,7 +1981,7 @@ class SourceTab extends AbstractTab { return q >= 0 ? uri.substring(0, q) : uri; } - private static int lineIndent(String line) { + static int lineIndent(String line) { int indent = 0; for (int i = 0; i < line.length(); i++) { if (line.charAt(i) == ' ') { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java index f696cd38d0de..fcbeea91604c 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java @@ -118,17 +118,6 @@ class SourceViewer { Set<Integer> scan(List<JsonObject> codeData); } - /** - * A link on a line to where it leads; callersOf is the endpoint of a route sent to from more than one place, whose - * link opens a popup to choose one (CAMEL-25411), else null. - */ - record JumpLink(String routeId, String filePath, int targetLine, String callersOf) { - - JumpLink(String routeId, String filePath, int targetLine) { - this(routeId, filePath, targetLine, null); - } - } - private boolean visible; private List<String> lines = Collections.emptyList(); private List<JsonObject> codeData = Collections.emptyList(); @@ -2845,7 +2834,7 @@ class SourceViewer { if (isSelected) { linkStyle = linkStyle.patch(selBg); } - spans.add(Span.styled(" ↵ " + jl.routeId(), linkStyle)); + spans.add(Span.styled(jl.hint(), linkStyle)); } liveColumn.addBodyTypes(spans, lineIndex, plainMode, isSelected ? selBg : null, viewportWidth - 1 + hSkip); Line full = Line.from(spans); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TopologyGroupFolds.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TopologyGroupFolds.java index dddbaef0c676..b2b4a69f8fa2 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TopologyGroupFolds.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TopologyGroupFolds.java @@ -17,7 +17,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui; import java.util.ArrayList; -import java.util.HashSet; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -94,7 +94,7 @@ final class TopologyGroupFolds { nodes.addAll(kept); List<TopologyEdgeInfo> moved = new ArrayList<>(); - Set<String> seen = new HashSet<>(); + Map<String, TopologyEdgeInfo> seen = new HashMap<>(); for (TopologyEdgeInfo e : edges) { String from = boxOf.getOrDefault(e.fromRouteId, e.fromRouteId); String to = boxOf.getOrDefault(e.toRouteId, e.toRouteId); @@ -102,10 +102,14 @@ final class TopologyGroupFolds { // between the routes of the group continue; } - if (seen.add(from + "\u0000" + to + "\u0000" + e.kind)) { + TopologyEdgeInfo known = seen.putIfAbsent(from + "\u0000" + to + "\u0000" + e.kind, e); + if (known == null) { e.fromRouteId = from; e.toRouteId = to; moved.add(e); + } else if (e.reply) { + // one of the routes of the group waits for the answer: the box does + known.reply = true; } } edges.clear(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramColors.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramColors.java index 86416c408201..ca51b93708f1 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramColors.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramColors.java @@ -33,6 +33,8 @@ public final class DiagramColors { static final char T_UP = '┴'; static final char CROSS = '┼'; static final char ARROW = '▼'; + // the head of an edge whose source route waits for the answer: there and back + static final char REPLY_ARROW = '⇅'; static final char DASH_V = '┆'; static final char DASH_H = '┄'; 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 da63efad5199..cbf10d9559a6 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 @@ -601,6 +601,8 @@ public class TopologyDiagramWidget implements Widget { } boolean dashed = isExternal(edge.from) || isExternal(edge.to) || ERROR_HANDLING.equals(edge.to.nodeType); + // ⇅ the route waits for the answer (a call, a request-reply over a broker), ▼ it hands the message off + char head = edge.reply ? REPLY_ARROW : ARROW; char vChar = dashed ? DASH_V : V; char hChar = dashed ? DASH_H : H; boolean edgeHighlighted = !dashed @@ -617,7 +619,7 @@ public class TopologyDiagramWidget implements Widget { for (int r = fromBottom; r < toTop - 1; r++) { plotLine(buffer, area, r, fromCx, vChar, edgeStyle); } - setChar(buffer, area, toTop - 1, toCx, ARROW, edgeStyle); + setChar(buffer, area, toTop - 1, toCx, head, edgeStyle); } else { int midRow = fromBottom + (toTop - fromBottom) / 2; @@ -637,7 +639,7 @@ public class TopologyDiagramWidget implements Widget { for (int r = midRow + 1; r < toTop - 1; r++) { plotLine(buffer, area, r, toCx, vChar, edgeStyle); } - setChar(buffer, area, toTop - 1, toCx, ARROW, edgeStyle); + setChar(buffer, area, toTop - 1, toCx, head, edgeStyle); } } 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 6e2c4afb5c6d..74e696d1fdb4 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 @@ -33,6 +33,12 @@ Each box represents a route. The first line is the route ID, the second line shows the `from` endpoint, and the bottom line shows metrics when enabled. Arrows show how routes connect. +The head of an arrow says whether the route waits for the answer +of the route it sends to: +- `⇅` it waits: a call to a `direct` route, or a request-reply over + a broker (`exchangePattern=InOut`) +- `▼` it hands the message off: a queue, a topic, a `wireTap` + ## Metrics When metrics are enabled, each route box shows exchange counts: diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md index 340c3775f65c..04773a4400d0 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md @@ -188,6 +188,11 @@ Lines with `to:`, `toD:`, `wireTap:`, or similar endpoints that reference another route show a **↵ routeId** indicator. Press **Enter** on such a line to jump to the target route's definition (within the same file or across files). Reverse links are shown on `from:` lines, indicating which route calls this one. +Before the route, **↳** says the route waits for the answer (a `direct` route, +`exchangePattern=InOut`, an `enrich`) and **⇢** that it hands the message off +(a queue, a topic, a `wireTap`), as in the About view. While the integration +runs, and its Diagram, Routes or About has been open, the running routes decide +it: a route that consumes a request (from `platform-http`) waits on a queue too. This works for YAML, XML and Java DSL routes, across all the folders of the project and files of different DSLs (build output and `src/test` are left out); Java routes are read without compiling them. The case and otherwise of a diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorProjectInfoTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorProjectInfoTest.java index bc9d3224205b..e6c65d9cbeb8 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorProjectInfoTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorProjectInfoTest.java @@ -157,7 +157,7 @@ class SourceEditorProjectInfoTest { SourceTab tab = newTab(); assertThat(tab.loadDirectory(dir)).isTrue(); - Map<Integer, SourceViewer.JumpLink> links = tab.computeJumpLinks(route); + Map<Integer, JumpLink> links = tab.computeJumpLinks(route); assertThat(links.get(5).routeId()).isEqualTo("orders"); assertThat(links.get(5).filePath()).endsWith("OrderService.java"); assertThat(links.get(5).targetLine()).isEqualTo(2); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java index fe7d84bb4d54..1ebcca7a9b16 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java @@ -75,7 +75,7 @@ class SourceSizeTest { SendMessagePopup.handleKeyEvent 220 SourceEditAssist.java 2006 SourceViewer.handleEditKeyEvent 231 - SourceViewer.java 2953 + SourceViewer.java 2942 SourceViewer.renderContent 207 SourceViewer.renderEditMode 256 TuiToolDefinitions.addInteractionTools 183 diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTabJumpLinksTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTabJumpLinksTest.java index b7da0484a30b..a02cbc49a08f 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTabJumpLinksTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTabJumpLinksTest.java @@ -81,7 +81,7 @@ class SourceTabJumpLinksTest { SourceTab tab = newTab(); assertThat(tab.loadDirectory(dir)).isTrue(); - Map<Integer, SourceViewer.JumpLink> links = tab.computeJumpLinks(tickets); + Map<Integer, JumpLink> links = tab.computeJumpLinks(tickets); assertThat(links.get(11).routeId()).isEqualTo("billing"); assertThat(links.get(12).routeId()).isEqualTo("technical"); // a route without an id is named by its from endpoint @@ -129,7 +129,7 @@ class SourceTabJumpLinksTest { uri: kamelet:tag-order-action """); assertThat(tab.loadDirectory(dir)).isTrue(); - SourceViewer.JumpLink link = tab.computeJumpLinks(kamelet).get(6); + JumpLink link = tab.computeJumpLinks(kamelet).get(6); assertThat(link.routeId()).isEqualTo("2 callers"); assertThat(link.callersOf()).isEqualTo("kamelet:tag-order-action"); assertThat(tab.callersOf("kamelet:tag-order-action")) @@ -169,19 +169,124 @@ class SourceTabJumpLinksTest { SourceTab tab = newTab(); assertThat(tab.loadDirectory(dir)).isTrue(); - Map<Integer, SourceViewer.JumpLink> links = tab.computeJumpLinks(orders); + Map<Integer, JumpLink> links = tab.computeJumpLinks(orders); assertThat(links.get(5).routeId()).isEqualTo("billing"); assertThat(links.get(5).filePath()).isEqualTo(billing.toString()); assertThat(links.get(9).routeId()).isEqualTo("billing"); // and back: the from: line of the YAML route has two Java callers, to choose from in a popup (CAMEL-25411) - SourceViewer.JumpLink back = tab.computeJumpLinks(billing).get(2); + JumpLink back = tab.computeJumpLinks(billing).get(2); assertThat(back.routeId()).isEqualTo("2 callers"); assertThat(back.callersOf()).isEqualTo("direct:billing"); assertThat(tab.callersOf("direct:billing")).extracting(GotoRoutePopup.RouteItem::filePath) .containsOnly(orders.toString()); } + @Test + void aHintSaysWhetherTheRouteWaitsForTheAnswer() throws Exception { + // CAMEL-26823: ↳ the route waits for the answer, ⇢ it hands the message off, as the About view marks them + Path orders = Files.writeString(dir.resolve("orders.camel.yaml"), """ + - route: + id: reserve-order + from: + uri: seda:orders + steps: + - to: + uri: seda:stock + parameters: + exchangePattern: InOut + - to: direct:audit + - wireTap: + uri: seda:metrics + - to: seda:reserved + - route: + id: stock-service + from: + uri: seda:stock + steps: + - log: stock + - route: + id: audit + from: + uri: direct:audit + steps: + - log: audit + - route: + id: metrics + from: + uri: seda:metrics + steps: + - log: metrics + - route: + id: shipping + from: + uri: seda:reserved + steps: + - log: ship + """); + Path quotes = Files.writeString(dir.resolve("Quotes.java"), """ + import org.apache.camel.builder.RouteBuilder; + + public class Quotes extends RouteBuilder { + public void configure() { + from("timer:quote").routeId("quotes") + .to("seda:stock?exchangePattern=InOut") + .to("seda:reserved"); + } + } + """); + SourceTab tab = newTab(); + assertThat(tab.loadDirectory(dir)).isTrue(); + + List<String> lines = Files.readAllLines(orders); + Map<Integer, JumpLink> links = tab.computeJumpLinks(orders); + assertThat(links.get(lineOf(lines, "uri: seda:stock")).hint()).isEqualTo(" ↵ ↳ stock-service"); + assertThat(links.get(lineOf(lines, "- to: direct:audit")).hint()).isEqualTo(" ↵ ↳ audit"); + assertThat(links.get(lineOf(lines, "uri: seda:metrics")).hint()).isEqualTo(" ↵ ⇢ metrics"); + assertThat(links.get(lineOf(lines, "- to: seda:reserved")).hint()).isEqualTo(" ↵ ⇢ shipping"); + // and back: the from line of a route called from one place says how its caller reaches it + assertThat(links.get(lineOf(lines, "uri: direct:audit") - 1).hint()).isEqualTo(" ↵ ↳ reserve-order"); + + // a Java route asks for the answer in the uri + Map<Integer, JumpLink> java = tab.computeJumpLinks(quotes); + assertThat(java.get(5).hint()).isEqualTo(" ↵ ↳ stock-service"); + assertThat(java.get(6).hint()).isEqualTo(" ↵ ⇢ shipping"); + } + + @Test + void howAStepReachesTheRouteItSendsTo() { + assertThat(SendHow.of("to", "direct:audit", false)).isEqualTo("call"); + assertThat(SendHow.of("to", "kamelet:my-sink", false)).isEqualTo("call"); + assertThat(SendHow.of("to", "jms:queue:stock?exchangePattern=InOut", false)).isEqualTo("call"); + assertThat(SendHow.of("to", "jms:queue:stock?exchangePattern=InOnly", false)).isEqualTo("async"); + assertThat(SendHow.of("to", "seda:stock", true)).isEqualTo("call"); + assertThat(SendHow.of("enrich", "seda:prices", false)).isEqualTo("call"); + assertThat(SendHow.of("wireTap", "direct:audit", false)).isEqualTo("async"); + assertThat(SendHow.of("to", "seda:reserved", false)).isEqualTo("async"); + assertThat(SendHow.of("deadLetterChannel", "seda:dead", false)).isNull(); + assertThat(SendHow.of("pollEnrich", "seda:prices", false)).isNull(); + + List<String> block = List.of( + " - to:", + " uri: seda:stock", + " pattern: \"InOut\"", + " - to: seda:next"); + assertThat(SendHow.blockSaysInOut(block, 0, 8)).isTrue(); + assertThat(SendHow.blockSaysInOut(block.subList(0, 2), 0, 8)).isFalse(); + // the next step is not part of the block + assertThat(SendHow.blockSaysInOut(List.of(" - to:", " uri: seda:a", " - to:", + " pattern: InOut"), 0, 8)).isFalse(); + } + + private static int lineOf(List<String> lines, String text) { + for (int i = 0; i < lines.size(); i++) { + if (lines.get(i).trim().equals(text)) { + return i; + } + } + throw new IllegalArgumentException(text); + } + @Test void xmlRoutesLinkToAndFromTheOthers() throws Exception { // CAMEL-25196: XML routes have the jump links of the YAML and Java ones @@ -205,10 +310,10 @@ class SourceTabJumpLinksTest { SourceTab tab = newTab(); assertThat(tab.loadDirectory(dir)).isTrue(); - SourceViewer.JumpLink link = tab.computeJumpLinks(orders).get(3); + JumpLink link = tab.computeJumpLinks(orders).get(3); assertThat(link.routeId()).isEqualTo("billing"); assertThat(link.filePath()).isEqualTo(billing.toString()); - SourceViewer.JumpLink back = tab.computeJumpLinks(billing).get(2); + JumpLink back = tab.computeJumpLinks(billing).get(2); assertThat(back.routeId()).isEqualTo("orders"); assertThat(back.targetLine()).isEqualTo(3); } @@ -251,7 +356,7 @@ class SourceTabJumpLinksTest { tab.showSourceDirectory(dir); assertThat(tab.routeSources()).containsExactlyInAnyOrder(orders, billing); - SourceViewer.JumpLink link = tab.computeJumpLinks(orders).get(5); + JumpLink link = tab.computeJumpLinks(orders).get(5); assertThat(link.routeId()).isEqualTo("billing"); assertThat(link.filePath()).isEqualTo(billing.toString()); assertThat(tab.computeJumpLinks(billing).get(4).filePath()).isEqualTo(orders.toString()); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TopologyGroupFoldsTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TopologyGroupFoldsTest.java index 748e96de5a4d..9b07d473227b 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TopologyGroupFoldsTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TopologyGroupFoldsTest.java @@ -79,6 +79,21 @@ class TopologyGroupFoldsTest { assertThat(TopologyGroupFolds.groupOf("orders")).isNull(); } + @Test + void aFoldedGroupWaitsWhenOneOfItsRoutesIsWaitedOn() { + // CAMEL-26823: orders hands off to audit but waits for archive; the one link to the box waits + List<TopologyNodeInfo> nodes = new ArrayList<>( + List.of(route("orders", 10, 0), route("audit", 8, 1), route("archive", 9, 2))); + TopologyEdgeInfo archive = call("orders", "archive"); + archive.reply = true; + List<TopologyEdgeInfo> edges = new ArrayList<>(List.of(call("orders", "audit"), archive)); + + TopologyGroupFolds.fold(nodes, edges, tags, Set.of("utility")); + + assertThat(edges).extracting(e -> e.fromRouteId + ">" + e.toRouteId + (e.reply ? " waits" : "")) + .containsExactly("orders>group:utility waits"); + } + @Test void nothingFoldedLeavesTheTopologyAsItIs() { List<TopologyNodeInfo> nodes = new ArrayList<>(List.of(route("orders", 10, 0), route("audit", 8, 1))); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyReplyArrowTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyReplyArrowTest.java new file mode 100644 index 000000000000..0bec779990e3 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyReplyArrowTest.java @@ -0,0 +1,89 @@ +/* + * 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.ArrayList; +import java.util.List; + +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.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * CAMEL-26823: the head of an edge in the topology says whether the route waits for the answer of the route it sends + * to: ⇅ there and back (a direct call, a request-reply over a broker), ▼ a hand-off. + */ +class TopologyReplyArrowTest { + + @Test + void anEdgeTheRouteWaitsOnEndsInATwoWayHead() { + TopologyEdgeInfo stock = edge("reserve-order", "stock-service", "seda:stock", true); + TopologyEdgeInfo shipping = edge("reserve-order", "shipping", "seda:reserved", false); + TopologyLayoutEngine engine = new TopologyLayoutEngine(); + TopologyLayoutResult layout = engine.layout( + new ArrayList<>(List.of(route("reserve-order"), route("stock-service"), route("shipping"))), + new ArrayList<>(List.of(stock, shipping))); + assertThat(layout.edges).extracting(e -> e.to.routeId + (e.reply ? " waits" : "")) + .containsExactlyInAnyOrder("stock-service waits", "shipping"); + + TopologyDiagramWidget widget = new TopologyDiagramWidget(layout, engine.getNodeWidth(), -1, 0, 0, false, false); + Rect area = new Rect(0, 0, Math.max(100, widget.getTotalCols()), Math.max(20, widget.getTotalRows())); + Buffer buffer = Buffer.empty(area); + widget.render(area, buffer); + + assertThat(headAbove(buffer, widget, "stock-service")).isEqualTo("⇅"); + assertThat(headAbove(buffer, widget, "shipping")).isEqualTo("▼"); + } + + /** The arrow head on the row above the box of the route. */ + private static String headAbove(Buffer buffer, TopologyDiagramWidget widget, String routeId) { + TopologyDiagramWidget.NodeBox box = widget.getNodeBoxes().stream() + .filter(b -> routeId.equals(b.routeId())).findFirst().orElseThrow(); + StringBuilder heads = new StringBuilder(); + for (int col = box.startCol(); col <= box.endCol(); col++) { + String symbol = buffer.get(col, box.startRow() - 1).symbol(); + if ("⇅".equals(symbol) || "▼".equals(symbol)) { + heads.append(symbol); + } + } + return heads.toString(); + } + + private static TopologyNodeInfo route(String id) { + TopologyNodeInfo n = new TopologyNodeInfo(); + n.routeId = id; + n.from = "seda:" + id; + n.nodeType = "route"; + return n; + } + + private static TopologyEdgeInfo edge(String from, String to, String endpoint, boolean reply) { + TopologyEdgeInfo e = new TopologyEdgeInfo(); + e.fromRouteId = from; + e.toRouteId = to; + e.endpoint = endpoint; + e.connectionType = "internal"; + e.reply = reply; + return e; + } +}
