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

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 09e606b1fafb chore: camel-jbang - the topology and About's Out show 
what a recipient list or a toD sent to, as the runtime saw it (#29508)
09e606b1fafb is described below

commit 09e606b1fafb0839c7af94cce21e333b67a7db13
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Nov 9 22:52:21 2026 +0100

    chore: camel-jbang - the topology and About's Out show what a recipient 
list or a toD sent to, as the runtime saw it (#29508)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../dsl/jbang/core/commands/tui/AboutView.java     |  15 ++-
 .../jbang/core/commands/tui/DiagramTopology.java   |  48 +++++++++
 .../dsl/jbang/core/commands/tui/ObservedOuts.java  | 109 +++++++++++++++++++++
 .../src/main/resources/tui/help/overview.md        |   5 +-
 .../core/commands/tui/DiagramTopologyTest.java     |  39 ++++++++
 .../jbang/core/commands/tui/ObservedOutsTest.java  |  83 ++++++++++++++++
 6 files changed, 295 insertions(+), 4 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutView.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutView.java
index fafed6b1176f..09c7ccf48fa3 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutView.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutView.java
@@ -98,6 +98,8 @@ final class AboutView {
     private long stripsEnd = -1;
     // the columns of the strips as last drawn, for the title of What happened
     private int stripColumns = STRIP_COLUMNS;
+    // the width of the text of the Out column, as the last render had it
+    private int outWidth = 40;
     // the line of In, Routes or Out the cursor is on (↑ ↓), in the order of 
the columns; -1 when none is
     private int cursor = -1;
     // the column the cursor is in: 0 In, 1 Routes, 2 Out, or -1 when it is on 
nothing; its panel has the focus border
@@ -468,6 +470,8 @@ final class AboutView {
         ProjectOverview.Overview o = overview;
         About.Model m = model;
         int width = area.width();
+        // the text of the Out column: its 30 % of the width, less the border, 
padding and the space before it
+        outWidth = Math.max(0, width * 30 / 100 - 6);
         List<Line> head = head(info, o, width);
         int spark = sparkWidth(width);
         Columns columns = columns(o, m, info, spark, false);
@@ -961,7 +965,10 @@ final class AboutView {
             routeTargets.add(new Target(r.routeId(), null));
         }
 
-        for (About.Out s : m.out()) {
+        // and the ones the integration sent to that its routes do not name (a 
toD, a recipient list)
+        List<About.Out> outs = new ArrayList<>(m.out());
+        outs.addAll(ObservedOuts.of(info, m.out()));
+        for (About.Out s : outs) {
             if (spaced && !out.isEmpty()) {
                 out.add(Line.from(Span.raw("")));
                 outTargets.add(null);
@@ -976,7 +983,9 @@ final class AboutView {
             // minutes, where a history of the sends would start with the TUI
             long[] perSecond = s.route() != null ? routePerSecond(info, 
s.route()) : null;
             if (perSecond != null) {
-                from.addAll(sparkLine("  ", perSecond, Math.max(8, 
Math.min(spark, spark + 10 - s.route().length())))
+                // the sparkline in what "    from <route>  " and the rate 
leave of the column
+                int room = outWidth - 9 - s.route().length() - 2 - 8;
+                from.addAll(sparkLine("  ", perSecond, Math.max(4, 
Math.min(spark, room)))
                         .spans());
             }
             out.add(Line.from(from));
@@ -1043,7 +1052,7 @@ final class AboutView {
         Long answer = null;
         for (EndpointInfo ep : info.endpoints) {
             if ("out".equals(ep.direction) && (route == null || 
route.equals(ep.routeId))
-                    && base.equals(baseUri(ep.uri))) {
+                    && ObservedOuts.matches(base, baseUri(ep.uri))) {
                 answer = (answer != null ? answer : 0) + ep.hits;
             }
         }
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 b56c095b5bdf..c97a69d92e81 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
@@ -18,9 +18,11 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
 
 import java.util.Collection;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Set;
 import java.util.function.Consumer;
 
 import org.apache.camel.util.json.JsonArray;
@@ -194,9 +196,55 @@ final class DiagramTopology {
                 addStepStatistics(route, r);
             }
         });
+        addObservedCalls(answer, info);
         return answer;
     }
 
+    /**
+     * The calls between routes that the integration made but the topology of 
its routes cannot know: a recipient list,
+     * a toD or a dynamic router computes the endpoint as it routes. The 
endpoints a route sent to (as the runtime
+     * endpoint registry counted them) that another route consumes are calls 
from the one to the other.
+     */
+    static void addObservedCalls(JsonObject topology, IntegrationInfo info) {
+        Map<String, String> consumers = new HashMap<>();
+        for (RouteInfo r : info.routes) {
+            if (r.from != null) {
+                consumers.putIfAbsent(endpointKey(r.from), r.routeId);
+            }
+        }
+        Collection<?> known = topology.getCollection("edges");
+        JsonArray edges = known != null ? new JsonArray(known) : new 
JsonArray();
+        Set<String> calls = new HashSet<>();
+        for (Object o : edges) {
+            if (o instanceof Map<?, ?> e) {
+                calls.add(e.get("fromRouteId") + ">" + e.get("toRouteId"));
+            }
+        }
+        boolean added = false;
+        for (EndpointInfo ep : info.endpoints) {
+            if (!"out".equals(ep.direction) || ep.routeId == null || ep.uri == 
null || ep.hits <= 0) {
+                continue;
+            }
+            String target = consumers.get(endpointKey(ep.uri));
+            if (target == null || target.equals(ep.routeId) || 
!calls.add(ep.routeId + ">" + target)) {
+                continue;
+            }
+            JsonObject edge = new JsonObject();
+            edge.put("fromRouteId", ep.routeId);
+            edge.put("toRouteId", target);
+            edge.put("endpoint", ep.uri);
+            edge.put("connectionType", ep.component != null ? ep.component : 
"internal");
+            edge.put("kind", "call");
+            // seen at runtime, not in the routes
+            edge.put("observed", true);
+            edges.add(edge);
+            added = true;
+        }
+        if (added) {
+            topology.put("edges", edges);
+        }
+    }
+
     /**
      * A system the route reads from has the messages of the route; one it 
sends to has those of the steps of the route
      * that send to it.
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ObservedOuts.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ObservedOuts.java
new file mode 100644
index 000000000000..19701a8ee5b0
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ObservedOuts.java
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.camel.dsl.jbang.core.commands.ai.About;
+import org.apache.camel.dsl.jbang.core.commands.ai.AboutReport;
+
+/**
+ * The systems an integration sent to that its routes do not name: a toD or a 
recipient list computes the endpoint as it
+ * routes (http://localhost:8080/stock/${sku}), so the About view, which reads 
the routes, does not know them. The
+ * runtime endpoint registry counted each one; the URIs a route built for one 
service are one line
+ * (http://localhost:8080/stock/…).
+ */
+final class ObservedOuts {
+
+    /** Marks a line that stands for the URIs under it. */
+    static final String MORE = "…";
+
+    private ObservedOuts() {
+    }
+
+    /** The outs the integration sent to that are not among those of its 
routes, for the Out column of About. */
+    static List<About.Out> of(IntegrationInfo info, List<About.Out> known) {
+        Set<String> knownUris = new HashSet<>();
+        for (About.Out o : known) {
+            knownUris.add(AboutReport.baseUri(o.uri()));
+        }
+        // the endpoints each route consumes: what a consumer sends back on 
them is its reply (rest, platform-http)
+        Set<String> consumed = new HashSet<>();
+        for (EndpointInfo ep : info.endpoints) {
+            if ("in".equals(ep.direction) && ep.routeId != null && ep.uri != 
null) {
+                consumed.add(ep.routeId + "|" + AboutReport.baseUri(ep.uri));
+            }
+        }
+        Map<String, List<String>> groups = new LinkedHashMap<>();
+        Map<String, String> routes = new LinkedHashMap<>();
+        for (EndpointInfo ep : info.endpoints) {
+            if (!"out".equals(ep.direction) || !"remote".equals(ep.scope) || 
ep.routeId == null || ep.uri == null
+                    || ep.hits <= 0) {
+                continue;
+            }
+            String base = AboutReport.baseUri(ep.uri);
+            if (knownUris.contains(base) || consumed.contains(ep.routeId + "|" 
+ base)) {
+                continue;
+            }
+            String key = ep.routeId + "|" + group(base);
+            // as the endpoint is written, without its options
+            int q = ep.uri.indexOf('?');
+            groups.computeIfAbsent(key, k -> new ArrayList<>()).add(q >= 0 ? 
ep.uri.substring(0, q) : ep.uri);
+            routes.put(key, ep.routeId);
+        }
+        List<About.Out> answer = new ArrayList<>();
+        for (var e : groups.entrySet()) {
+            List<String> uris = e.getValue().stream().distinct().toList();
+            String uri = uris.size() == 1 ? uris.get(0) : group(uris.get(0)) + 
MORE;
+            answer.add(new About.Out(scheme(uri), uri, "out", 
routes.get(e.getKey())));
+        }
+        return answer;
+    }
+
+    /**
+     * The service of a URI with a path: all but its last segment 
(http:localhost:8080/stock/ of
+     * http:localhost:8080/stock/CAMEL-MUG), as a computed URI puts the id or 
the name there; a URI without a path is
+     * its own service.
+     */
+    static String group(String base) {
+        int scheme = base.indexOf(':');
+        int slash = base.lastIndexOf('/');
+        // after the host: the // after the scheme is not a path
+        int start = base.startsWith("//", scheme + 1) ? scheme + 3 : scheme + 
1;
+        int host = base.indexOf('/', start);
+        return slash > scheme && host >= 0 && slash > host ? base.substring(0, 
slash + 1) : base;
+    }
+
+    private static String scheme(String uri) {
+        int colon = uri.indexOf(':');
+        String scheme = colon > 0 ? uri.substring(0, colon) : uri;
+        return scheme.startsWith("http") || scheme.endsWith("http") ? "http" : 
scheme;
+    }
+
+    /** Whether the URI of a line stands for the URIs under it, and so matches 
the base URI of an endpoint. */
+    static boolean matches(String lineUri, String endpointBaseUri) {
+        if (lineUri.endsWith(MORE)) {
+            return endpointBaseUri.startsWith(lineUri.substring(0, 
lineUri.length() - MORE.length()));
+        }
+        return lineUri.equals(endpointBaseUri);
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/overview.md
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/overview.md
index 4fb5a4be9ed5..2ea670c691f2 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/overview.md
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/overview.md
@@ -24,7 +24,10 @@ happened to it, with no AI:
   one handed off with `seda:` with `⇢`) and what it talks to, from its
   sources. Each line has the exchanges since it started (for an
   endpoint, the sends to it), and In and Out a sparkline of the last 5
-  minutes with the rate now, or `idle`.
+  minutes with the rate now, or `idle`. Out also has the systems the
+  integration sent to that its sources do not name, as a `toD` or a
+  recipient list computes the endpoint: the URIs one route built for one
+  service are one line (`http://localhost:8080/stock/…`).
 - **Last 5m** — the exchanges and how they ended, the cause that
   happened most and when (a chaos rule marked 🎲), and the exchanges
   since it started.
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTopologyTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTopologyTest.java
index 02ff1dd67f03..bdf52a961c50 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTopologyTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTopologyTest.java
@@ -99,6 +99,45 @@ class DiagramTopologyTest {
         return StatusParser.parseIntegration(ProcessHandle.current(), 
json(STATUS));
     }
 
+    @Test
+    void aRouteCalledThroughARecipientListIsAnEdgeAsTheRuntimeSawIt() {
+        // route a sends to direct:b in its routes, and to direct:c through a 
recipient list the topology cannot see
+        IntegrationInfo info = new IntegrationInfo();
+        info.routes.add(route("a", "kafka://in"));
+        info.routes.add(route("b", "direct://b"));
+        info.routes.add(route("c", "direct://c"));
+        info.endpoints.add(out("a", "direct://b", 4));
+        info.endpoints.add(out("a", "direct://c?block=true", 3));
+        // a route that sends to its own consumer, and one that never sent
+        info.endpoints.add(out("c", "direct://c", 1));
+        info.endpoints.add(out("b", "direct://a-never", 0));
+
+        JsonObject topology = DiagramTopology.withNumbers(json(TOPOLOGY), 
info);
+
+        assertThat(TopologyHelper.parseEdges(topology))
+                .extracting(e -> e.fromRouteId, e -> e.toRouteId)
+                .containsExactly(tuple("a", "b"), tuple("a", "c"));
+        // the structure kept for the next time has only the calls of the 
routes
+        assertThat(TopologyHelper.parseEdges(json(TOPOLOGY))).hasSize(1);
+    }
+
+    private static RouteInfo route(String id, String from) {
+        RouteInfo r = new RouteInfo();
+        r.routeId = id;
+        r.from = from;
+        return r;
+    }
+
+    private static EndpointInfo out(String routeId, String uri, long hits) {
+        EndpointInfo ep = new EndpointInfo();
+        ep.routeId = routeId;
+        ep.uri = uri;
+        ep.direction = "out";
+        ep.component = "direct";
+        ep.hits = hits;
+        return ep;
+    }
+
     @Test
     void theRoutesHaveTheirTotalsFromTheStatusFile() {
         JsonObject topology = DiagramTopology.withNumbers(json(TOPOLOGY), 
status());
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ObservedOutsTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ObservedOutsTest.java
new file mode 100644
index 000000000000..d7f9c3f9af73
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ObservedOutsTest.java
@@ -0,0 +1,83 @@
+/*
+ * 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.List;
+
+import org.apache.camel.dsl.jbang.core.commands.ai.About;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
+
+/**
+ * The systems an integration sent to that its routes do not name, as the 
http-client example has them: check-stock
+ * calls the stock service with a toD per SKU, and stock-service answers its 
REST calls.
+ */
+class ObservedOutsTest {
+
+    @Test
+    void theUrisAToDBuiltForOneServiceAreOneLine() {
+        IntegrationInfo info = new IntegrationInfo();
+        info.endpoints.add(endpoint("check-stock", "in", 
"file://orders?noop=true", "remote", 3));
+        info.endpoints.add(endpoint("check-stock", "out",
+                
"http://localhost:8080/stock/CAMEL-MUG?throwExceptionOnFailure=false";, 
"remote", 3));
+        info.endpoints.add(endpoint("check-stock", "out",
+                
"http://localhost:8080/stock/CAMEL-TSHIRT?throwExceptionOnFailure=false";, 
"remote", 2));
+        info.endpoints.add(endpoint("check-stock", "out",
+                
"http://localhost:8080/stock/CAMEL-CAP?throwExceptionOnFailure=false";, 
"remote", 1));
+        // the reply of the REST route is no system it sends to
+        info.endpoints.add(endpoint("stock-service", "in", 
"platform-http:///stock/%7Bsku%7D";, "remote", 6));
+        info.endpoints.add(endpoint("stock-service", "out", 
"platform-http:///stock/%7Bsku%7D";, "remote", 6));
+        // nor a call to another route, or a log
+        info.endpoints.add(endpoint("check-stock", "out", "direct://picking", 
"internal", 3));
+
+        List<About.Out> outs = ObservedOuts.of(info, List.of());
+
+        assertThat(outs).extracting(About.Out::category, About.Out::uri, 
About.Out::route)
+                .containsExactly(tuple("http", 
"http://localhost:8080/stock/…";, "check-stock"));
+        // the line has the calls of the three URIs
+        assertThat(AboutView.outHits(info, outs.get(0).uri(), 
"check-stock")).isEqualTo(6);
+    }
+
+    @Test
+    void anOutTheRoutesNameIsNotTwice() {
+        IntegrationInfo info = new IntegrationInfo();
+        info.endpoints.add(endpoint("audit", "out", 
"kafka:audit?brokers=localhost:9092", "remote", 4));
+
+        assertThat(ObservedOuts.of(info, List.of(new About.Out("messaging", 
"kafka:audit", "out", "audit")))).isEmpty();
+        // one URI of a service is its own line
+        assertThat(ObservedOuts.of(info, 
List.of())).extracting(About.Out::uri).containsExactly("kafka:audit");
+    }
+
+    @Test
+    void theServiceOfAUriIsAllButItsLastSegment() {
+        
assertThat(ObservedOuts.group("http://localhost:8080/stock/CAMEL-MUG";)).isEqualTo("http://localhost:8080/stock/";);
+        
assertThat(ObservedOuts.group("http://localhost:8080";)).isEqualTo("http://localhost:8080";);
+        assertThat(ObservedOuts.group("kafka:audit")).isEqualTo("kafka:audit");
+    }
+
+    private static EndpointInfo endpoint(String routeId, String direction, 
String uri, String scope, long hits) {
+        EndpointInfo ep = new EndpointInfo();
+        ep.routeId = routeId;
+        ep.direction = direction;
+        ep.uri = uri;
+        ep.scope = scope;
+        ep.hits = hits;
+        return ep;
+    }
+}

Reply via email to