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