This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-23656/route-topology-service in repository https://gitbox.apache.org/repos/asf/camel.git
commit 7a222b7d87236bcf2e2f1a8bf0b6caecda642f42 Author: Claus Ibsen <[email protected]> AuthorDate: Mon Jun 1 13:39:30 2026 +0200 CAMEL-23656: Add route topology service to compute inter-route relationships Co-Authored-By: Claude Opus 4.6 <[email protected]> --- .../apache/camel/catalog/dev-consoles.properties | 1 + .../camel/catalog/dev-consoles/route-topology.json | 15 ++ .../org/apache/camel/spi/RouteTopologyDumper.java | 77 +++++++++ .../camel/impl/engine/AbstractCamelContext.java | 9 + .../apache/camel/dev-console/route-topology.json | 15 ++ .../org/apache/camel/dev-console/route-topology | 2 + .../org/apache/camel/dev-consoles.properties | 2 +- .../impl/console/RouteTopologyDevConsole.java | 97 +++++++++++ .../impl/console/RouteBasedDevConsoleTest.java | 1 + .../impl/console/RouteTopologyDevConsoleTest.java | 183 +++++++++++++++++++++ .../org/apache/camel/route-topology-dumper | 2 + .../camel/impl/DefaultRouteTopologyDumper.java | 89 ++++++++++ .../org/apache/camel/support/PluginHelper.java | 15 ++ .../camel-jbang-cmd-route-topology.adoc | 26 +++ .../ROOT/pages/jbang-commands/camel-jbang-cmd.adoc | 1 + .../camel/cli/connector/LocalCliConnector.java | 14 ++ .../META-INF/camel-jbang-commands-metadata.json | 2 +- .../dsl/jbang/core/commands/CamelJBangMain.java | 1 + .../commands/action/CamelRouteTopologyAction.java | 114 +++++++++++++ 19 files changed, 664 insertions(+), 2 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles.properties b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles.properties index 9b3a109b8baa..8804844e3cf7 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles.properties +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles.properties @@ -48,6 +48,7 @@ route-diagram route-dump route-group route-structure +route-topology send service sftp diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/route-topology.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/route-topology.json new file mode 100644 index 000000000000..738a98765ec9 --- /dev/null +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/route-topology.json @@ -0,0 +1,15 @@ +{ + "console": { + "kind": "console", + "group": "camel", + "name": "route-topology", + "title": "Route Topology", + "description": "Route topology showing inter-route connections", + "deprecated": false, + "javaType": "org.apache.camel.impl.console.RouteTopologyDevConsole", + "groupId": "org.apache.camel", + "artifactId": "camel-console", + "version": "4.21.0-SNAPSHOT" + } +} + diff --git a/core/camel-api/src/main/java/org/apache/camel/spi/RouteTopologyDumper.java b/core/camel-api/src/main/java/org/apache/camel/spi/RouteTopologyDumper.java new file mode 100644 index 000000000000..d7d6af83f953 --- /dev/null +++ b/core/camel-api/src/main/java/org/apache/camel/spi/RouteTopologyDumper.java @@ -0,0 +1,77 @@ +/* + * 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.spi; + +import java.util.List; + +import org.apache.camel.CamelContext; + +/** + * SPI for computing inter-route topology, showing how routes connect to each other through shared endpoints. + * + * @since 4.21 + */ +public interface RouteTopologyDumper { + + /** + * Service factory key. + */ + String FACTORY = "route-topology-dumper"; + + /** + * A node in the topology representing a route. + * + * @param routeId the route id + * @param from the input endpoint URI (scheme:context-path, query parameters stripped) + * @param fromScheme the component scheme of the input endpoint + * @param nodeType the type of node: "route" for regular routes, "trigger" for timer/quartz/cron/scheduler + * @since 4.21 + */ + record TopologyNode(String routeId, String from, String fromScheme, String nodeType) { + } + + /** + * An edge in the topology representing a connection between two routes via a shared endpoint. + * + * @param fromRouteId the source route id (the route that sends to the endpoint) + * @param toRouteId the target route id (the route that consumes from the endpoint) + * @param endpoint the shared endpoint URI (scheme:context-path, query parameters stripped) + * @param connectionType the type of connection: "internal" for direct/seda, "external" for remote components + * @since 4.21 + */ + record TopologyEdge(String fromRouteId, String toRouteId, String endpoint, String connectionType) { + } + + /** + * The result of computing route topology. + * + * @param nodes the route nodes + * @param edges the connections between routes + * @since 4.21 + */ + record TopologyResult(List<TopologyNode> nodes, List<TopologyEdge> edges) { + } + + /** + * Computes the inter-route topology by analyzing route definitions and matching endpoints across routes. + * + * @param context the CamelContext + * @return the topology result with nodes and edges + */ + TopologyResult dumpTopology(CamelContext context); + +} diff --git a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/AbstractCamelContext.java b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/AbstractCamelContext.java index ceb556bfae20..d2b056f2ff58 100644 --- a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/AbstractCamelContext.java +++ b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/AbstractCamelContext.java @@ -168,6 +168,7 @@ import org.apache.camel.spi.RouteFactory; import org.apache.camel.spi.RoutePolicyFactory; import org.apache.camel.spi.RouteStartupOrder; import org.apache.camel.spi.RouteTemplateParameterSource; +import org.apache.camel.spi.RouteTopologyDumper; import org.apache.camel.spi.RoutesLoader; import org.apache.camel.spi.RuntimeEndpointRegistry; import org.apache.camel.spi.ShutdownStrategy; @@ -414,6 +415,7 @@ public abstract class AbstractCamelContext extends BaseService camelContextExtension.lazyAddContextPlugin(SimpleFunctionRegistry.class, this::createSimpleFunctionRegistry); camelContextExtension.lazyAddContextPlugin(RestRegistry.class, this::createRestRegistry); camelContextExtension.lazyAddContextPlugin(RouteDiagramDumper.class, this::createRouteDiagramDumper); + camelContextExtension.lazyAddContextPlugin(RouteTopologyDumper.class, this::createRouteTopologyDumper); } protected static <T> T lookup(CamelContext context, String ref, Class<T> type) { @@ -4391,6 +4393,13 @@ public abstract class AbstractCamelContext extends BaseService "camel-diagram"); } + protected RouteTopologyDumper createRouteTopologyDumper() { + return ResolverHelper.resolveMandatoryService(getCamelContextReference(), + RouteTopologyDumper.FACTORY, + RouteTopologyDumper.class, + "camel-core-engine"); + } + @Override public String getGlobalOption(String key) { String value = getGlobalOptions().get(key); diff --git a/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/route-topology.json b/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/route-topology.json new file mode 100644 index 000000000000..738a98765ec9 --- /dev/null +++ b/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/route-topology.json @@ -0,0 +1,15 @@ +{ + "console": { + "kind": "console", + "group": "camel", + "name": "route-topology", + "title": "Route Topology", + "description": "Route topology showing inter-route connections", + "deprecated": false, + "javaType": "org.apache.camel.impl.console.RouteTopologyDevConsole", + "groupId": "org.apache.camel", + "artifactId": "camel-console", + "version": "4.21.0-SNAPSHOT" + } +} + diff --git a/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-console/route-topology b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-console/route-topology new file mode 100644 index 000000000000..1329fdb6c797 --- /dev/null +++ b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-console/route-topology @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.impl.console.RouteTopologyDevConsole diff --git a/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties index 90006f59d203..84bbd4e88131 100644 --- a/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties +++ b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties @@ -1,5 +1,5 @@ # Generated by camel build tools - do NOT edit this file! -dev-consoles=bean blocked browse circuit-breaker consumer context debug endpoint errors eval-language event gc health inflight internal-tasks java-security jvm log memory message-history processor producer properties receive reload rest rest-spec route route-controller route-dump route-group route-structure send service simple-language source startup-recorder system-properties thread top trace transformers type-converters variables +dev-consoles=bean blocked browse circuit-breaker consumer context debug endpoint errors eval-language event gc health inflight internal-tasks java-security jvm log memory message-history processor producer properties receive reload rest rest-spec route route-controller route-dump route-group route-structure route-topology send service simple-language source startup-recorder system-properties thread top trace transformers type-converters variables groupId=org.apache.camel artifactId=camel-console version=4.21.0-SNAPSHOT diff --git a/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteTopologyDevConsole.java b/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteTopologyDevConsole.java new file mode 100644 index 000000000000..6dcb2c099193 --- /dev/null +++ b/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteTopologyDevConsole.java @@ -0,0 +1,97 @@ +/* + * 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.impl.console; + +import java.util.Map; + +import org.apache.camel.spi.RouteTopologyDumper; +import org.apache.camel.spi.RouteTopologyDumper.TopologyEdge; +import org.apache.camel.spi.RouteTopologyDumper.TopologyNode; +import org.apache.camel.spi.RouteTopologyDumper.TopologyResult; +import org.apache.camel.spi.annotations.DevConsole; +import org.apache.camel.support.PluginHelper; +import org.apache.camel.support.console.AbstractDevConsole; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; + +@DevConsole(name = "route-topology", description = "Route topology showing inter-route connections") +public class RouteTopologyDevConsole extends AbstractDevConsole { + + public RouteTopologyDevConsole() { + super("camel", "route-topology", "Route Topology", "Route topology showing inter-route connections"); + } + + @Override + protected String doCallText(Map<String, Object> options) { + RouteTopologyDumper dumper = PluginHelper.getRouteTopologyDumper(getCamelContext()); + if (dumper == null) { + return ""; + } + TopologyResult result = dumper.dumpTopology(getCamelContext()); + + StringBuilder sb = new StringBuilder(); + sb.append(String.format("Route Topology (%d routes, %d connections)%n%n", + result.nodes().size(), result.edges().size())); + + for (TopologyNode node : result.nodes()) { + sb.append(String.format(" %s (%s) type=%s%n", node.routeId(), node.from(), node.nodeType())); + + for (TopologyEdge edge : result.edges()) { + if (edge.fromRouteId().equals(node.routeId())) { + sb.append(String.format(" --> %s via %s [%s]%n", + edge.toRouteId(), edge.endpoint(), edge.connectionType())); + } + } + } + return sb.toString(); + } + + @Override + protected JsonObject doCallJson(Map<String, Object> options) { + JsonObject root = new JsonObject(); + RouteTopologyDumper dumper = PluginHelper.getRouteTopologyDumper(getCamelContext()); + if (dumper == null) { + return root; + } + TopologyResult result = dumper.dumpTopology(getCamelContext()); + + JsonArray nodesArr = new JsonArray(); + for (TopologyNode node : result.nodes()) { + JsonObject jo = new JsonObject(); + jo.put("routeId", node.routeId()); + jo.put("from", node.from()); + jo.put("fromScheme", node.fromScheme()); + jo.put("nodeType", node.nodeType()); + nodesArr.add(jo); + } + root.put("nodes", nodesArr); + + JsonArray edgesArr = new JsonArray(); + for (TopologyEdge edge : result.edges()) { + JsonObject jo = new JsonObject(); + jo.put("fromRouteId", edge.fromRouteId()); + jo.put("toRouteId", edge.toRouteId()); + jo.put("endpoint", edge.endpoint()); + jo.put("connectionType", edge.connectionType()); + edgesArr.add(jo); + } + root.put("edges", edgesArr); + + return root; + } + +} diff --git a/core/camel-console/src/test/java/org/apache/camel/impl/console/RouteBasedDevConsoleTest.java b/core/camel-console/src/test/java/org/apache/camel/impl/console/RouteBasedDevConsoleTest.java index 18aed23ef61b..a2399c08aec7 100644 --- a/core/camel-console/src/test/java/org/apache/camel/impl/console/RouteBasedDevConsoleTest.java +++ b/core/camel-console/src/test/java/org/apache/camel/impl/console/RouteBasedDevConsoleTest.java @@ -60,6 +60,7 @@ public class RouteBasedDevConsoleTest extends AbstractDevConsoleTest { Arguments.of("route-dump", "routes"), Arguments.of("route-group", "routeGroups"), Arguments.of("route-structure", "routes"), + Arguments.of("route-topology", "nodes"), Arguments.of("source", "routes")); } diff --git a/core/camel-console/src/test/java/org/apache/camel/impl/console/RouteTopologyDevConsoleTest.java b/core/camel-console/src/test/java/org/apache/camel/impl/console/RouteTopologyDevConsoleTest.java new file mode 100644 index 000000000000..f97c87fdd974 --- /dev/null +++ b/core/camel-console/src/test/java/org/apache/camel/impl/console/RouteTopologyDevConsoleTest.java @@ -0,0 +1,183 @@ +/* + * 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.impl.console; + +import org.apache.camel.ContextTestSupport; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.console.DevConsole; +import org.apache.camel.support.PluginHelper; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RouteTopologyDevConsoleTest extends ContextTestSupport { + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("timer:tick").routeId("trigger") + .to("direct:process"); + + from("direct:process").routeId("processor") + .to("direct:validate") + .to("mock:result"); + + from("direct:validate").routeId("validator") + .to("mock:validated"); + + from("seda:loop").routeId("looper") + .choice() + .when(simple("${body} == 'retry'")) + .to("seda:loop") + .otherwise() + .to("mock:done"); + + from("direct:isolated").routeId("isolated") + .to("mock:nowhere"); + } + }; + } + + @Test + void testConsoleExists() { + DevConsole console = PluginHelper.getDevConsoleResolver(context).resolveDevConsole("route-topology"); + assertNotNull(console); + assertEquals("route-topology", console.getId()); + } + + @Test + void testJsonOutput() { + DevConsole console = PluginHelper.getDevConsoleResolver(context).resolveDevConsole("route-topology"); + assertNotNull(console); + + JsonObject json = (JsonObject) console.call(DevConsole.MediaType.JSON); + assertNotNull(json); + + JsonArray nodes = (JsonArray) json.get("nodes"); + assertNotNull(nodes); + assertEquals(5, nodes.size()); + + JsonArray edges = (JsonArray) json.get("edges"); + assertNotNull(edges); + assertTrue(edges.size() >= 3); + } + + @Test + void testTriggerNodeType() { + DevConsole console = PluginHelper.getDevConsoleResolver(context).resolveDevConsole("route-topology"); + JsonObject json = (JsonObject) console.call(DevConsole.MediaType.JSON); + JsonArray nodes = (JsonArray) json.get("nodes"); + + JsonObject triggerNode = findNode(nodes, "trigger"); + assertNotNull(triggerNode); + assertEquals("trigger", triggerNode.getString("nodeType")); + assertEquals("timer", triggerNode.getString("fromScheme")); + + JsonObject processorNode = findNode(nodes, "processor"); + assertNotNull(processorNode); + assertEquals("route", processorNode.getString("nodeType")); + } + + @Test + void testInternalDirectConnection() { + DevConsole console = PluginHelper.getDevConsoleResolver(context).resolveDevConsole("route-topology"); + JsonObject json = (JsonObject) console.call(DevConsole.MediaType.JSON); + JsonArray edges = (JsonArray) json.get("edges"); + + JsonObject edge = findEdge(edges, "trigger", "processor"); + assertNotNull(edge, "Expected edge from trigger to processor via direct:process"); + assertEquals("direct:process", edge.getString("endpoint")); + assertEquals("internal", edge.getString("connectionType")); + } + + @Test + void testChainedConnections() { + DevConsole console = PluginHelper.getDevConsoleResolver(context).resolveDevConsole("route-topology"); + JsonObject json = (JsonObject) console.call(DevConsole.MediaType.JSON); + JsonArray edges = (JsonArray) json.get("edges"); + + JsonObject edge = findEdge(edges, "processor", "validator"); + assertNotNull(edge, "Expected edge from processor to validator via direct:validate"); + assertEquals("direct:validate", edge.getString("endpoint")); + assertEquals("internal", edge.getString("connectionType")); + } + + @Test + void testSelfReferencingRoute() { + DevConsole console = PluginHelper.getDevConsoleResolver(context).resolveDevConsole("route-topology"); + JsonObject json = (JsonObject) console.call(DevConsole.MediaType.JSON); + JsonArray edges = (JsonArray) json.get("edges"); + + JsonObject selfEdge = findEdge(edges, "looper", "looper"); + assertNotNull(selfEdge, "Expected self-referencing edge for looper via seda:loop"); + assertEquals("seda:loop", selfEdge.getString("endpoint")); + assertEquals("internal", selfEdge.getString("connectionType")); + } + + @Test + void testIsolatedRouteHasNoEdges() { + DevConsole console = PluginHelper.getDevConsoleResolver(context).resolveDevConsole("route-topology"); + JsonObject json = (JsonObject) console.call(DevConsole.MediaType.JSON); + JsonArray edges = (JsonArray) json.get("edges"); + + for (int i = 0; i < edges.size(); i++) { + JsonObject edge = (JsonObject) edges.get(i); + if ("isolated".equals(edge.getString("fromRouteId")) || "isolated".equals(edge.getString("toRouteId"))) { + org.junit.jupiter.api.Assertions.fail("Isolated route should have no edges but found: " + edge); + } + } + } + + @Test + void testTextOutput() { + DevConsole console = PluginHelper.getDevConsoleResolver(context).resolveDevConsole("route-topology"); + String text = (String) console.call(DevConsole.MediaType.TEXT); + assertNotNull(text); + assertTrue(text.contains("Route Topology")); + assertTrue(text.contains("trigger")); + assertTrue(text.contains("processor")); + assertTrue(text.contains("direct:process")); + } + + private JsonObject findNode(JsonArray nodes, String routeId) { + for (int i = 0; i < nodes.size(); i++) { + JsonObject node = (JsonObject) nodes.get(i); + if (routeId.equals(node.getString("routeId"))) { + return node; + } + } + return null; + } + + private JsonObject findEdge(JsonArray edges, String fromRouteId, String toRouteId) { + for (int i = 0; i < edges.size(); i++) { + JsonObject edge = (JsonObject) edges.get(i); + if (fromRouteId.equals(edge.getString("fromRouteId")) + && toRouteId.equals(edge.getString("toRouteId"))) { + return edge; + } + } + return null; + } + +} diff --git a/core/camel-core-engine/src/generated/resources/META-INF/services/org/apache/camel/route-topology-dumper b/core/camel-core-engine/src/generated/resources/META-INF/services/org/apache/camel/route-topology-dumper new file mode 100644 index 000000000000..e6e6b9eef2b7 --- /dev/null +++ b/core/camel-core-engine/src/generated/resources/META-INF/services/org/apache/camel/route-topology-dumper @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.impl.DefaultRouteTopologyDumper diff --git a/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultRouteTopologyDumper.java b/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultRouteTopologyDumper.java new file mode 100644 index 000000000000..112aba2539ed --- /dev/null +++ b/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultRouteTopologyDumper.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.impl; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.apache.camel.CamelContext; +import org.apache.camel.model.EndpointRequiredDefinition; +import org.apache.camel.model.Model; +import org.apache.camel.model.ProcessorDefinitionHelper; +import org.apache.camel.model.RouteDefinition; +import org.apache.camel.spi.RouteTopologyDumper; +import org.apache.camel.spi.annotations.JdkService; +import org.apache.camel.util.URISupport; + +@JdkService(RouteTopologyDumper.FACTORY) +public class DefaultRouteTopologyDumper implements RouteTopologyDumper { + + private static final Set<String> TRIGGER_SCHEMES = Set.of("timer", "quartz", "cron", "scheduler"); + private static final Set<String> INTERNAL_SCHEMES = Set.of("direct", "seda", "vertx", "disruptor", "disruptor-vm"); + + @Override + public TopologyResult dumpTopology(CamelContext context) { + Model model = context.getCamelContextExtension().getContextPlugin(Model.class); + List<RouteDefinition> routeDefs = model.getRouteDefinitions(); + + List<TopologyNode> nodes = new ArrayList<>(); + Map<String, List<String>> inputUriToRouteIds = new HashMap<>(); + + for (RouteDefinition rd : routeDefs) { + String inputUri = rd.getInput().getEndpointUri(); + String normalizedInput = URISupport.stripQuery(inputUri); + String scheme = extractScheme(normalizedInput); + String nodeType = TRIGGER_SCHEMES.contains(scheme) ? "trigger" : "route"; + + nodes.add(new TopologyNode(rd.getRouteId(), normalizedInput, scheme, nodeType)); + inputUriToRouteIds + .computeIfAbsent(normalizedInput, k -> new ArrayList<>()) + .add(rd.getRouteId()); + } + + List<TopologyEdge> edges = new ArrayList<>(); + for (RouteDefinition rd : routeDefs) { + Collection<EndpointRequiredDefinition> outputs + = ProcessorDefinitionHelper.filterTypeInOutputs( + rd.getOutputs(), EndpointRequiredDefinition.class); + + for (EndpointRequiredDefinition erd : outputs) { + String outputUri = URISupport.stripQuery(erd.getEndpointUri()); + String scheme = extractScheme(outputUri); + + List<String> targetRouteIds = inputUriToRouteIds.get(outputUri); + if (targetRouteIds != null) { + String connType = INTERNAL_SCHEMES.contains(scheme) ? "internal" : "external"; + for (String targetId : targetRouteIds) { + edges.add(new TopologyEdge(rd.getRouteId(), targetId, outputUri, connType)); + } + } + } + } + + return new TopologyResult(nodes, edges); + } + + private static String extractScheme(String uri) { + int idx = uri.indexOf(':'); + return idx > 0 ? uri.substring(0, idx) : uri; + } + +} diff --git a/core/camel-support/src/main/java/org/apache/camel/support/PluginHelper.java b/core/camel-support/src/main/java/org/apache/camel/support/PluginHelper.java index 1d270ef76137..99c33772c33c 100644 --- a/core/camel-support/src/main/java/org/apache/camel/support/PluginHelper.java +++ b/core/camel-support/src/main/java/org/apache/camel/support/PluginHelper.java @@ -56,6 +56,7 @@ import org.apache.camel.spi.RestBindingJaxbDataFormatFactory; import org.apache.camel.spi.RestRegistry; import org.apache.camel.spi.RouteDiagramDumper; import org.apache.camel.spi.RouteFactory; +import org.apache.camel.spi.RouteTopologyDumper; import org.apache.camel.spi.RoutesLoader; import org.apache.camel.spi.SimpleFunctionRegistry; import org.apache.camel.spi.UnitOfWorkFactory; @@ -583,6 +584,20 @@ public final class PluginHelper { return extendedCamelContext.getContextPlugin(ModelToStructureDumper.class); } + /** + * Gets the {@link RouteTopologyDumper} to use. + */ + public static RouteTopologyDumper getRouteTopologyDumper(CamelContext camelContext) { + return getRouteTopologyDumper(camelContext.getCamelContextExtension()); + } + + /** + * Gets the {@link RouteTopologyDumper} to use. + */ + public static RouteTopologyDumper getRouteTopologyDumper(ExtendedCamelContext extendedCamelContext) { + return extendedCamelContext.getContextPlugin(RouteTopologyDumper.class); + } + /** * Gets the {@link DeferServiceFactory} to use. */ diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-topology.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-topology.adoc new file mode 100644 index 000000000000..88713e908bfd --- /dev/null +++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-topology.adoc @@ -0,0 +1,26 @@ + +// AUTO-GENERATED by camel-package-maven-plugin - DO NOT EDIT THIS FILE += camel cmd route-topology + +Display inter-route topology connections + + +== Usage + +[source,bash] +---- +camel cmd route-topology [options] +---- + + + +== Options + +[cols="2,5,1,2",options="header"] +|=== +| Option | Description | Default | Type +| `--json` | Output in JSON Format | | boolean +| `-h,--help` | Display the help and sub-commands | | boolean +|=== + + diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc index 5ce6c69d3117..4a9792305122 100644 --- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc +++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc @@ -30,6 +30,7 @@ camel cmd [options] | xref:jbang-commands/camel-jbang-cmd-resume-route.adoc[resume-route] | Resume Camel routes | xref:jbang-commands/camel-jbang-cmd-route-diagram.adoc[route-diagram] | Display Camel route diagram in the terminal | xref:jbang-commands/camel-jbang-cmd-route-structure.adoc[route-structure] | Dump Camel route structure +| xref:jbang-commands/camel-jbang-cmd-route-topology.adoc[route-topology] | Display inter-route topology connections | xref:jbang-commands/camel-jbang-cmd-send.adoc[send] | Send messages to endpoints | xref:jbang-commands/camel-jbang-cmd-start-group.adoc[start-group] | Start Camel route groups | xref:jbang-commands/camel-jbang-cmd-start-route.adoc[start-route] | Start Camel routes diff --git a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java index 2f87e61735d4..9f849c3e9767 100644 --- a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java +++ b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java @@ -345,6 +345,8 @@ public class LocalCliConnector extends ServiceSupport implements CliConnector, C doActionRouteDumpTask(root); } else if ("route-structure".equals(action)) { doActionRouteStructureTask(root); + } else if ("route-topology".equals(action)) { + doActionRouteTopologyTask(); } else if ("route-controller".equals(action)) { doActionRouteControllerTask(root); } else if ("startup-recorder".equals(action)) { @@ -748,6 +750,18 @@ public class LocalCliConnector extends ServiceSupport implements CliConnector, C } } + private void doActionRouteTopologyTask() throws Exception { + DevConsole dc = camelContext.getCamelContextExtension().getContextPlugin(DevConsoleRegistry.class) + .resolveById("route-topology"); + if (dc != null) { + JsonObject json = (JsonObject) dc.call(DevConsole.MediaType.JSON, Map.of()); + LOG.trace("Updating output file: {}", outputFile); + IOHelper.writeText(json.toJson(), outputFile); + } else { + IOHelper.writeText("{}", outputFile); + } + } + private void doActionSourceTask(JsonObject root) throws Exception { DevConsole dc = camelContext.getCamelContextExtension().getContextPlugin(DevConsoleRegistry.class) .resolveById("source"); diff --git a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json index d8fc3f98ab8f..dc2e07a409a1 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json +++ b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json @@ -3,7 +3,7 @@ { "name": "ask", "fullName": "ask", "description": "Ask a question about a running Camel application using AI", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Ask", "options": [ { "names": "--api-key", "description": "API key. Also reads ANTHROPIC_API_KEY, OPENAI_API_KEY, or LLM_API_KEY env vars", "javaType": "java.lang.String", "type": "string" }, { "names": "--api-type", "description": "API type: 'ollama', 'openai', or 'anthropic'", "javaType": "LlmClient.ApiType", "type" [...] { "name": "bind", "fullName": "bind", "description": "DEPRECATED: Bind source and sink Kamelets as a new Camel integration", "deprecated": true, "sourceClass": "org.apache.camel.dsl.jbang.core.commands.bind.Bind", "options": [ { "names": "--error-handler", "description": "Add error handler (none|log|sink:<endpoint>). Sink endpoints are expected in the format [[apigroup\/]version:]kind:[namespace\/]name, plain Camel URIs or Kamelet name.", "javaType": "java.lang.String", "type": "stri [...] { "name": "catalog", "fullName": "catalog", "description": "List artifacts from Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.catalog.CatalogCommand", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "component", "fullName": "catalog component", "description": "List components from the Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.co [...] - { "name": "cmd", "fullName": "cmd", "description": "Performs commands in the running Camel integrations, such as start\/stop route, or change logging levels.", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "browse", "fullName": "cmd browse", "description": "Browse pending messages on endpoints [...] + { "name": "cmd", "fullName": "cmd", "description": "Performs commands in the running Camel integrations, such as start\/stop route, or change logging levels.", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "browse", "fullName": "cmd browse", "description": "Browse pending messages on endpoints [...] { "name": "completion", "fullName": "completion", "description": "Generate completion script for bash\/zsh", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Complete", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ] }, { "name": "config", "fullName": "config", "description": "Get and set user configuration values", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.config.ConfigCommand", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "get", "fullName": "config get", "description": "Display user configuration value", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.config. [...] { "name": "debug", "fullName": "debug", "description": "Debug local Camel integration", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Debug", "options": [ { "names": "--ago", "description": "Use ago instead of yyyy-MM-dd HH:mm:ss in timestamp.", "javaType": "boolean", "type": "boolean" }, { "names": "--background", "description": "Run in the background", "defaultValue": "false", "javaType": "boolean", "type": "boolean" }, { "names": "--background-wait", "description": "To [...] diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java index 04a0f9d39a6a..e5102ba30f99 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java @@ -117,6 +117,7 @@ public class CamelJBangMain implements Callable<Integer> { .addSubcommand("resume-route", new CommandLine(new CamelRouteResumeAction(this))) .addSubcommand("route-diagram", new CommandLine(new CamelRouteDiagramAction(this))) .addSubcommand("route-structure", new CommandLine(new CamelRouteStructureAction(this))) + .addSubcommand("route-topology", new CommandLine(new CamelRouteTopologyAction(this))) .addSubcommand("send", new CommandLine(new CamelSendAction(this))) .addSubcommand("start-group", new CommandLine(new CamelRouteGroupStartAction(this))) .addSubcommand("start-route", new CommandLine(new CamelRouteStartAction(this))) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteTopologyAction.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteTopologyAction.java new file mode 100644 index 000000000000..57f9674229b9 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteTopologyAction.java @@ -0,0 +1,114 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.action; + +import java.nio.file.Path; +import java.util.List; + +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.dsl.jbang.core.common.PathUtils; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import picocli.CommandLine; +import picocli.CommandLine.Command; + +@Command(name = "route-topology", description = "Display inter-route topology connections", sortOptions = false, + showDefaultValues = true, + footer = { + "%nExamples:", + " camel cmd route-topology", + " camel cmd route-topology --json" }) +public class CamelRouteTopologyAction extends ActionBaseCommand { + + @CommandLine.Parameters(description = "Name or pid of a running Camel integration", arity = "0..1") + String name = "*"; + + @CommandLine.Option(names = { "--json" }, + description = "Output in JSON Format") + boolean jsonOutput; + + public CamelRouteTopologyAction(CamelJBangMain main) { + super(main); + } + + @Override + public Integer doCall() throws Exception { + List<Long> pids = findPids(name); + if (pids.isEmpty()) { + printer().println("No running Camel integration found"); + return 1; + } + if (pids.size() > 1) { + printer().println("Name or pid " + name + " matches " + pids.size() + + " running Camel integrations. Specify a name or PID that matches exactly one."); + return 1; + } + + long pid = pids.get(0); + Path outputFile = prepareAction(Long.toString(pid), "route-topology", root -> { + }); + + JsonObject jo = getJsonObject(outputFile); + if (jo != null) { + if (jsonOutput) { + String dump = Jsoner.prettyPrint(jo.toJson(), 2); + printer().println(dump); + } else { + printTopology(jo); + } + } else { + printer().println("Response from running Camel with PID " + pid + " not received within 5 seconds"); + return 1; + } + + PathUtils.deleteFile(outputFile); + return 0; + } + + private void printTopology(JsonObject jo) { + JsonArray nodes = (JsonArray) jo.get("nodes"); + JsonArray edges = (JsonArray) jo.get("edges"); + + int nodeCount = nodes != null ? nodes.size() : 0; + int edgeCount = edges != null ? edges.size() : 0; + printer().printf("Route Topology (%d routes, %d connections)%n%n", nodeCount, edgeCount); + + if (nodes != null) { + for (Object n : nodes) { + JsonObject node = (JsonObject) n; + printer().printf(" %s (%s) type=%s%n", + node.getString("routeId"), + node.getString("from"), + node.getString("nodeType")); + + if (edges != null) { + for (Object e : edges) { + JsonObject edge = (JsonObject) e; + if (node.getString("routeId").equals(edge.getString("fromRouteId"))) { + printer().printf(" --> %s via %s [%s]%n", + edge.getString("toRouteId"), + edge.getString("endpoint"), + edge.getString("connectionType")); + } + } + } + } + } + } + +}
