This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch tui-about-runtime-topology in repository https://gitbox.apache.org/repos/asf/camel.git
commit d55fcd297ad54e82ca033da36c29da4779848389 Author: Claus Ibsen <[email protected]> AuthorDate: Wed Nov 11 08:22:29 2026 +0100 chore: camel-jbang - About has the links of the runtime: a queue between the routes of an integration is a call or a hand-off, and no In or Out About read how the routes reach each other from the sources, by scheme: a hop over a broker between the routes of one integration was a message from outside. connect-service/jms-request-reply had its queues as In and Out, all six routes at the top, and 14 exchanges for 3 orders. For a running integration About now takes the links from its route topology (CAMEL-26823), whatever the component: a route the sender waits for (direct, a request-reply over JMS) is a call (↳), one it hands the message to (seda, a topic, a wire tap) a hand-off (⇢), and a dead letter queue a hand-off on failure. In and Out leave out a destination only the integration itself sends to or consumes; one others send to as well stays with +N from outside, from the in and out hits of the endpoint. Last 5m and "since it started" count a message once. An opened project that does not run keeps the links of its sources. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../ROOT/pages/camel-jbang-tui-observe.adoc | 4 +- .../dsl/jbang/core/commands/tui/AboutView.java | 47 +++++- .../dsl/jbang/core/commands/tui/RuntimeLinks.java | 180 +++++++++++++++++++++ .../src/main/resources/tui/help/overview.md | 6 +- .../jbang/core/commands/tui/RuntimeLinksTest.java | 144 +++++++++++++++++ 5 files changed, 372 insertions(+), 9 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc index b4f00f5c1ec4..8450a29f878e 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc @@ -30,7 +30,9 @@ README, else the descriptions of the routes, else the overview an AI wrote in `c and *s* the summary of the project. * *In*, *Routes* and *Out* say what starts it (a timer or a cron with the period or schedule of its properties, its profile applied; the operations of an OpenAPI contract the REST DSL serves, from the contract), its routes (a route called with `direct` sits under its caller with `↳`, -one handed off with `seda` or a `wireTap` with `⇢`) and what it talks to, with the exchanges since it started and a sparkline of the +one handed off with `seda` or a `wireTap` with `⇢`; for a running integration as its route topology has it, so a +queue or topic of a broker between its own routes is `↳` for a request-reply, `⇢` for a hand-off and `on failure` for a +dead letter queue, and no In or Out unless messages came from outside too) and what it talks to, with the exchanges since it started and a sparkline of the last 5 minutes. *Up* and *Down* select a line, and *Enter* opens a route drilled down on the Diagram, or an endpoint with its details on the Endpoint tab. * *Last 5m* has the exchanges and how they ended, with the cause that happened most, and *What happened* a strip per route 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 05bb6aba74dd..25399325e8c7 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 @@ -90,6 +90,8 @@ final class AboutView { private final MonitorContext ctx; private volatile ProjectOverview.Overview overview; private volatile About.Model model; + // how the routes of the running integration reach each other; null as the sources have it + private volatile RuntimeLinks links; private volatile String purpose; private volatile String purposeFrom; private volatile boolean loading; @@ -130,6 +132,30 @@ final class AboutView { this.ctx = ctx; } + /** + * The links of the running integration (its route topology, as the Diagram has it); null when it does not run, or + * does not answer, and the model is as the sources have it. + */ + private RuntimeLinks runtimeLinks(IntegrationInfo info) { + if (info.phantom || info.pid == null || !alive(info.pid)) { + return null; + } + try { + JsonObject topology = ctx.diagramTopology.load(ctx, info.pid); + return topology != null ? RuntimeLinks.of(topology) : null; + } catch (RuntimeException e) { + return null; + } + } + + private static boolean alive(String pid) { + try { + return ProcessHandle.of(Long.parseLong(pid)).map(ProcessHandle::isAlive).orElse(false); + } catch (NumberFormatException e) { + return false; + } + } + /** Reads the sources of the integration in the background, again when they may have changed. */ private void load(IntegrationInfo info, Consumer<Runnable> onRenderThread) { Path d = info.directory != null ? Path.of(info.directory) : null; @@ -152,9 +178,12 @@ final class AboutView { Thread t = new Thread(() -> { try { ProjectOverview.Overview o = ProjectOverview.analyze(d, ArchitectureView.catalog()); - About.Model m = About.of(d, o); + // how the routes reach each other as the runtime has it, a hop over a broker included + RuntimeLinks rl = runtimeLinks(info); + About.Model m = rl != null ? rl.apply(About.of(d, o), info) : About.of(d, o); Path r = About.readme(d); onRenderThread.accept(() -> { + links = rl; readme = r; overview = o; model = m; @@ -1034,7 +1063,8 @@ final class AboutView { String how = r.how() == null ? "" : "call".equals(r.how()) ? "↳ " : "⇢ "; spans.add(Span.raw(" " + " ".repeat(r.depth()) + how)); spans.add(Span.styled(r.routeId(), Style.EMPTY.bold())); - spans.add(Span.styled(" " + r.from() + " → " + r.steps() + (r.steps() == 1 ? " step" : " steps"), + spans.add(Span.styled(" " + r.from() + " → " + r.steps() + (r.steps() == 1 ? " step" : " steps") + + ("error".equals(r.how()) ? ", on failure" : ""), Theme.muted())); Long total = totals.get(r.routeId()); if (total != null) { @@ -1046,7 +1076,9 @@ final class AboutView { // 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())); + RuntimeLinks rl = links; + // a destination a route of the integration consumes is no system outside + ObservedOuts.of(info, m.out()).stream().filter(s -> rl == null || !rl.consumedHere(s)).forEach(outs::add); for (About.Out s : outs) { if (spaced && !out.isEmpty()) { out.add(Line.from(Span.raw(""))); @@ -1402,11 +1434,12 @@ final class AboutView { ? " (the last exchange " + ChaosTab.shortTime((end - lastActive) * 1000) + " ago)" : " (quiet)", Theme.muted())); } - // what the routes that something starts did since the integration started, as their counters say + // what the routes that something from outside starts did since the integration started, as their counters + // say: not a route a queue of the integration itself feeds Set<String> entry = new LinkedHashSet<>(); - ProjectOverview.Overview o = overview; - if (o != null) { - o.entryPoints().forEach(e -> entry.add(e.route())); + About.Model shown = model; + if (shown != null) { + shown.in().forEach(e -> entry.add(e.route())); } long total = 0; long totalFailed = 0; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RuntimeLinks.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RuntimeLinks.java new file mode 100644 index 000000000000..e08863f6c460 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RuntimeLinks.java @@ -0,0 +1,180 @@ +/* + * 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.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +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.util.json.JsonObject; + +/** + * How the routes of a running integration reach each other, from its route topology (CAMEL-26823), whatever the + * component: a route the sender waits for (direct, a request-reply over a JMS queue) is a call, one it hands the + * message to (seda, a topic, a wire tap) a hand-off, and one it sends to only when it handles a failure (a dead letter + * queue) an error path. The sources of the integration name the links by their scheme only, and took a hop over a + * broker for a message from outside: jms-request-reply had its queues as In and Out, and 14 exchanges for 3 orders. + */ +final class RuntimeLinks { + + /** How a route reaches another: call, async (a hand-off) or error (only when it handles a failure). */ + record Link(String from, String to, String endpoint, String how) { + } + + private static final int MAX_DEPTH = 6; + + private final List<Link> links; + + private RuntimeLinks(List<Link> links) { + this.links = links; + } + + List<Link> links() { + return links; + } + + /** The links between different routes in the topology of the route-topology console. */ + static RuntimeLinks of(JsonObject topology) { + List<Link> answer = new ArrayList<>(); + Collection<?> edges = topology != null ? topology.getCollection("edges") : null; + if (edges != null) { + for (Object o : edges) { + if (!(o instanceof JsonObject e)) { + continue; + } + String from = e.getString("fromRouteId"); + String to = e.getString("toRouteId"); + if (from == null || to == null || from.equals(to)) { + continue; + } + // a hand-off unless the route waits for the answer; an edge the TUI adds for the sends the runtime saw + // (a recipient list) waits, as a call + String how = "error".equals(e.getString("kind")) ? "error" + : Boolean.TRUE.equals(e.get("reply")) || Boolean.TRUE.equals(e.get("observed")) ? "call" : "async"; + answer.add(new Link(from, to, e.getString("endpoint"), how)); + } + } + return new RuntimeLinks(answer); + } + + /** + * The model of the sources with the links of the runtime: the routes under the route that reaches them, In without + * an endpoint only the integration sends to (the rest of its messages, from outside, as +N from outside), and Out + * without one only the integration consumes. + */ + About.Model apply(About.Model m, IntegrationInfo info) { + if (links.isEmpty()) { + return m; + } + return new About.Model(m.purpose(), m.purposeFrom(), in(m.in(), info), routes(m.routes()), out(m.out())); + } + + private List<About.In> in(List<About.In> in, IntegrationInfo info) { + List<About.In> answer = new ArrayList<>(); + for (About.In e : in) { + String key = DiagramTopology.endpointKey(e.label()); + Link fed = links.stream() + .filter(l -> l.to().equals(e.route()) && key.equals(DiagramTopology.endpointKey(l.endpoint()))) + .findFirst().orElse(null); + if (fed == null) { + answer.add(e); + continue; + } + // what came in beyond what the routes of the integration sent there came from outside + long outside = hits(info, key, "in", e.route()) - hits(info, key, "out", null); + if (outside > 0) { + answer.add(new About.In(e.kind(), e.label() + " +" + outside + " from outside", e.route())); + } + } + return answer; + } + + private static long hits(IntegrationInfo info, String key, String direction, String route) { + long answer = 0; + for (EndpointInfo ep : info.endpoints) { + if (direction.equals(ep.direction) && ep.uri != null && (route == null || route.equals(ep.routeId)) + && key.equals(DiagramTopology.endpointKey(ep.uri))) { + answer += ep.hits; + } + } + return answer; + } + + private List<About.Out> out(List<About.Out> out) { + return out.stream().filter(o -> !consumedHere(o)).toList(); + } + + /** Whether the destination of an out is one a route of the integration consumes from: a hop, no system outside. */ + boolean consumedHere(About.Out o) { + String key = DiagramTopology.endpointKey(o.uri()); + return links.stream() + .anyMatch(l -> l.from().equals(o.route()) && key.equals(DiagramTopology.endpointKey(l.endpoint()))); + } + + private List<About.RouteLine> routes(List<About.RouteLine> lines) { + Map<String, About.RouteLine> byId = new LinkedHashMap<>(); + lines.forEach(r -> byId.putIfAbsent(r.routeId(), r)); + Map<String, List<Link>> from = new HashMap<>(); + Set<String> reached = new LinkedHashSet<>(); + for (Link l : links) { + if (byId.containsKey(l.from()) && byId.containsKey(l.to())) { + from.computeIfAbsent(l.from(), k -> new ArrayList<>()).add(l); + reached.add(l.to()); + } + } + List<About.RouteLine> answer = new ArrayList<>(); + Set<String> shown = new LinkedHashSet<>(); + for (String id : byId.keySet()) { + if (!reached.contains(id)) { + add(byId.get(id), 0, null, byId, from, shown, answer); + } + } + // what is left: routes reached only from a cycle + for (String id : byId.keySet()) { + add(byId.get(id), 0, null, byId, from, shown, answer); + } + return answer; + } + + private static void add( + About.RouteLine r, int depth, String how, Map<String, About.RouteLine> byId, Map<String, List<Link>> from, + Set<String> shown, List<About.RouteLine> answer) { + if (!shown.add(r.routeId()) || depth > MAX_DEPTH) { + return; + } + answer.add(new About.RouteLine(r.routeId(), depth, how, r.from(), r.steps())); + for (Link l : from.getOrDefault(r.routeId(), List.of())) { + add(byId.get(l.to()), depth + 1, l.how(), byId, from, shown, answer); + } + } + + /** How each route that another one reaches is reached: call, async or error; none for the others. */ + Map<String, String> howReached() { + Map<String, String> answer = new HashMap<>(); + for (Link l : links) { + // a route called by one and handed to by another: the call keeps it inside the exchange of its caller + answer.merge(l.to(), l.how(), (a, b) -> "call".equals(a) || "call".equals(b) ? "call" : a); + } + return answer; + } +} 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 7ca5cef11bf8..4ed570918470 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 @@ -25,7 +25,11 @@ happened to it, with no AI: - **In, Routes, Out** — what starts it (timers, HTTP, consumers, the operations of an OpenAPI contract the REST DSL serves), its routes (a route called with `direct:` sits under its caller with `↳`, - one handed off with `seda:` or a `wireTap` with `⇢`) and what it talks to, from its + one handed off with `seda:` or a `wireTap` with `⇢`; for a running + integration as its route topology has it, so a queue or topic between + its own routes is `↳` for a request-reply, `⇢` for a hand-off, and + `on failure` for a dead letter queue, and is no In or Out, unless + messages came from outside too: `+5 from outside`) 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`. Out also has the systems the diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RuntimeLinksTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RuntimeLinksTest.java new file mode 100644 index 000000000000..4a581ae7a80f --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RuntimeLinksTest.java @@ -0,0 +1,144 @@ +/* + * 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.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * connect-service/jms-request-reply: order-intake puts each order on a queue, reserve-order asks stock-service over JMS + * request-reply, publishes the reserved order on a topic for shipping and invoicing, and its dead letter channel moves + * the order it cannot reserve to a dead letter queue. The sources name the links by their scheme only: every queue was + * an In and an Out of the integration. + */ +class RuntimeLinksTest { + + private static About.Model sources() { + List<About.In> in = List.of( + new About.In("file", "file:orders", "order-intake"), + new About.In("jms", "jms:queue:orders", "reserve-order"), + new About.In("jms", "jms:queue:stock", "stock-service"), + new About.In("jms", "jms:queue:orders.dead", "dead-orders"), + new About.In("jms", "jms:topic:orders.reserved", "shipping"), + new About.In("jms", "jms:topic:orders.reserved", "invoicing")); + List<About.RouteLine> routes = List.of( + new About.RouteLine("order-intake", 0, null, "file", 4), + new About.RouteLine("reserve-order", 0, null, "jms", 6), + new About.RouteLine("stock-service", 0, null, "jms", 5), + new About.RouteLine("dead-orders", 0, null, "jms", 1), + new About.RouteLine("shipping", 0, null, "jms", 1), + new About.RouteLine("invoicing", 0, null, "jms", 1)); + List<About.Out> out = List.of( + new About.Out("messaging", "jms:queue:orders", "out", "order-intake"), + new About.Out("messaging", "jms:queue:stock", "in/out", "reserve-order"), + new About.Out("messaging", "jms:topic:orders.reserved", "out", "reserve-order"), + new About.Out("messaging", "jms:queue:orders.dead", "out", "reserve-order")); + return new About.Model("Each order asks a stock service", "from the comment", in, routes, out); + } + + private static JsonObject topology() { + JsonArray edges = new JsonArray(); + edges.add(edge("order-intake", "reserve-order", "jms:queue:orders", "call", false)); + edges.add(edge("reserve-order", "stock-service", "jms:queue:stock", "call", true)); + edges.add(edge("reserve-order", "shipping", "jms:topic:orders.reserved", "call", false)); + edges.add(edge("reserve-order", "invoicing", "jms:topic:orders.reserved", "call", false)); + edges.add(edge("reserve-order", "dead-orders", "jms:queue:orders.dead", "error", false)); + JsonObject topology = new JsonObject(); + topology.put("edges", edges); + return topology; + } + + private static JsonObject edge(String from, String to, String endpoint, String kind, boolean reply) { + JsonObject e = new JsonObject(); + e.put("fromRouteId", from); + e.put("toRouteId", to); + e.put("endpoint", endpoint); + e.put("kind", kind); + if (reply) { + e.put("reply", true); + } + return e; + } + + @Test + void theRoutesAreUnderTheRouteThatReachesThem() { + About.Model m = RuntimeLinks.of(topology()).apply(sources(), new IntegrationInfo()); + + assertThat(m.routes()).extracting(r -> " ".repeat(r.depth()) + (r.how() != null ? r.how() + " " : "") + r.routeId()) + .containsExactly( + "order-intake", + " async reserve-order", + " call stock-service", + " async shipping", + " async invoicing", + " error dead-orders"); + } + + @Test + void inAndOutAreWhatCrossesTheIntegration() { + About.Model m = RuntimeLinks.of(topology()).apply(sources(), new IntegrationInfo()); + + assertThat(m.in()).extracting(About.In::label).containsExactly("file:orders"); + assertThat(m.out()).isEmpty(); + } + + @Test + void anOutTheRuntimeSawOnAQueueOfTheIntegrationIsNoSystemOutside() { + RuntimeLinks links = RuntimeLinks.of(topology()); + + // as the runtime names it, with // after the scheme + assertThat(links.consumedHere(new About.Out("messaging", "jms://queue:orders", "out", "order-intake"))).isTrue(); + assertThat(links.consumedHere(new About.Out("messaging", "jms://queue:billing", "out", "order-intake"))).isFalse(); + } + + @Test + void aQueueOthersSendToTooStaysAnInWithWhatTheySent() { + // 3 orders from order-intake, and 5 more another application put on the queue + IntegrationInfo info = new IntegrationInfo(); + info.endpoints.add(endpoint("order-intake", "out", "jms://queue:orders", 3)); + info.endpoints.add(endpoint("reserve-order", "in", "jms://queue:orders", 8)); + + About.Model m = RuntimeLinks.of(topology()).apply(sources(), info); + + assertThat(m.in()).extracting(About.In::label).containsExactly("file:orders", "jms:queue:orders +5 from outside"); + } + + @Test + void withoutATopologyTheSourcesStay() { + About.Model m = sources(); + + assertThat(RuntimeLinks.of(null).apply(m, new IntegrationInfo())).isSameAs(m); + assertThat(RuntimeLinks.of(topology()).howReached()).containsEntry("stock-service", "call") + .containsEntry("shipping", "async").containsEntry("dead-orders", "error") + .doesNotContainKey("order-intake"); + } + + private static EndpointInfo endpoint(String route, String direction, String uri, long hits) { + EndpointInfo ep = new EndpointInfo(); + ep.routeId = route; + ep.direction = direction; + ep.uri = uri; + ep.hits = hits; + return ep; + } +}
