This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-26700-about in repository https://gitbox.apache.org/repos/asf/camel.git
commit a605d0526988b7b4ab2e2a686c62d703c60b3e5e Author: Claus Ibsen <[email protected]> AuthorDate: Sat Nov 7 16:58:13 2026 +0100 CAMEL-26700: camel get about, the purpose and the running part in camel_project_overview, and sparklines on In and Out The About model moves to camel-jbang-core (About: the purpose, what comes in, the routes under their caller, what goes out), so the screen and the CLI say the same. camel get about prints it with what has happened: the exchanges of each route and the sends to each endpoint since the start, and the last minutes from the timeline (how the exchanges ended, the cause that happened most, per route the worst with when, what a recovery recovered from, a circuit breaker open); --json for tools. camel_project_overview has the purpose, and running when the project runs. On the screen, In has the exchanges of the route an entry starts as a sparkline with the rate now, and Out the sends to each endpoint. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../jbang-commands/camel-jbang-get-about.adoc | 28 ++ .../ROOT/pages/jbang-commands/camel-jbang-get.adoc | 1 + .../META-INF/camel-jbang-commands-metadata.json | 2 +- .../dsl/jbang/core/commands/CamelJBangMain.java | 1 + .../camel/dsl/jbang/core/commands/ai/About.java | 235 ++++++++++++ .../dsl/jbang/core/commands/ai/AboutReport.java | 409 +++++++++++++++++++++ .../dsl/jbang/core/commands/ai/AuthoringTools.java | 13 +- .../dsl/jbang/core/commands/process/ListAbout.java | 118 ++++++ .../dsl/jbang/core/commands/ai/AboutTest.java | 139 +++++++ .../jbang/core/commands/process/ListAboutTest.java | 111 ++++++ .../src/test/resources/golden/cli-help.txt | 26 ++ .../jbang/core/commands/mcp/AuthoringTools.java | 8 +- .../dsl/jbang/core/commands/tui/AboutView.java | 271 +++++--------- .../dsl/jbang/core/commands/tui/AboutViewTest.java | 15 +- 14 files changed, 1190 insertions(+), 187 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-about.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-about.adoc new file mode 100644 index 000000000000..366e004a442b --- /dev/null +++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-about.adoc @@ -0,0 +1,28 @@ + +// AUTO-GENERATED by camel-package-maven-plugin - DO NOT EDIT THIS FILE += camel get about + +Get what an integration does and what has happened: its purpose, what comes in, its routes, what goes out, and the last minutes + + +== Usage + +[source,bash] +---- +camel get about [options] +---- + + + +== Options + +[cols="2,5,1,2",options="header"] +|=== +| Option | Description | Default | Type +| `--duration` | How long to keep watching or following, in millis or as a duration such as 30s (default: until Enter is pressed) | | long +| `--json` | Output in JSON Format | | boolean +| `--watch` | Execute periodically and showing output fullscreen | | boolean +| `-h,--help` | Display the help and sub-commands | | boolean +|=== + + diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get.adoc index 9d70f9a37293..3806af47a4aa 100644 --- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get.adoc +++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get.adoc @@ -18,6 +18,7 @@ camel get [options] [cols="2,5",options="header"] |=== | Subcommand | Description +| xref:jbang-commands/camel-jbang-get-about.adoc[about] | Get what an integration does and what has happened: its purpose, what comes in, its routes, what goes out, and the last minutes | xref:jbang-commands/camel-jbang-get-bean.adoc[bean] | List beans in a running Camel integration | xref:jbang-commands/camel-jbang-get-blocked.adoc[blocked] | Get blocked messages of Camel integrations | xref:jbang-commands/camel-jbang-get-chaos.adoc[chaos] | Get the chaos rules of running integrations, or their report (camel-chaos) 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 cb47db752241..39463c0717ac 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 @@ -15,7 +15,7 @@ { "name": "eval", "fullName": "eval", "description": "Evaluate Camel expressions and scripts", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.EvalCommand", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "expression", "fullName": "eval expression", "description": "Evaluates Camel expression", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.action.EvalEx [...] { "name": "explain", "fullName": "explain", "description": "Explain what a Camel route does using AI\/LLM", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Explain", "options": [ { "names": "--api-key", "description": "API key for authentication. Also reads ANTHROPIC_API_KEY, OPENAI_API_KEY, WATSONX_APIKEY, or LLM_API_KEY env vars", "javaType": "java.lang.String", "type": "string" }, { "names": "--api-type", "description": "API type: 'ollama', 'openai' (OpenAI-compatible), ' [...] { "name": "export", "fullName": "export", "description": "Export to other runtimes (Camel Main, Spring Boot, or Quarkus)", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Export", "options": [ { "names": "--build-property", "description": "Maven build properties, ex. --build-property=prop1=foo", "javaType": "java.util.List", "type": "array" }, { "names": "--camel-spring-boot-version", "description": "Camel version to use with Spring Boot", "javaType": "java.lang.String", "ty [...] - { "name": "get", "fullName": "get", "description": "Get status of Camel integrations", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.process.CamelStatus", "options": [ { "names": "--watch", "description": "Execute periodically and showing output fullscreen", "javaType": "boolean", "type": "boolean" }, { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "bean", "fullName": "get [...] + { "name": "get", "fullName": "get", "description": "Get status of Camel integrations", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.process.CamelStatus", "options": [ { "names": "--watch", "description": "Execute periodically and showing output fullscreen", "javaType": "boolean", "type": "boolean" }, { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "about", "fullName": "get [...] { "name": "harden", "fullName": "harden", "description": "Suggest security hardening for Camel routes using AI\/LLM", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Harden", "options": [ { "names": "--api-key", "description": "API key for authentication. Also reads OPENAI_API_KEY or LLM_API_KEY env vars", "javaType": "java.lang.String", "type": "string" }, { "names": "--api-type", "description": "API type: 'ollama' or 'openai' (OpenAI-compatible)", "defaultValue": "ollama", [...] { "name": "hawtio", "fullName": "hawtio", "description": "Launch Hawtio web console", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.process.Hawtio", "options": [ { "names": "--host", "description": "Hostname to bind the Hawtio web console to", "defaultValue": "127.0.0.1", "javaType": "java.lang.String", "type": "string" }, { "names": "--open-url,--openUrl", "description": "To automatic open Hawtio web console in the web browser (without an interactive terminal the default [...] { "name": "infra", "fullName": "infra", "description": "List and Run external services for testing and prototyping", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.infra.InfraCommand", "options": [ { "names": "--json", "description": "Output in JSON Format", "javaType": "boolean", "type": "boolean" }, { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "get", "fullName": "infra [...] 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 418028901fa3..1537f6f0519e 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 @@ -374,6 +374,7 @@ public class CamelJBangMain implements Callable<Integer> { .addSubcommand("sql-trace", new CommandLine(new ListSqlTrace(this))) .addSubcommand("startup-recorder", new CommandLine(new CamelStartupRecorderAction(this))) .addSubcommand("timeline", new CommandLine(new ListTimeline(this))) + .addSubcommand("about", new CommandLine(new ListAbout(this))) .addSubcommand("transformer", new CommandLine(new ListTransformer(this))) .addSubcommand("variable", new CommandLine(new ListVariable(this))) .addSubcommand("vault", new CommandLine(new ListVault(this)))); diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/About.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/About.java new file mode 100644 index 000000000000..99493fc0ce6e --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/About.java @@ -0,0 +1,235 @@ +/* + * 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.ai; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * About an integration (CAMEL-26700): what it does at a glance and without an AI, from the analysis of its sources + * ({@link ProjectOverview}). Its purpose in the words of its author, what comes in, its routes (a route called with + * direct or seda under the route that calls it) and what goes out. The About screen of the TUI and {@code camel get + * about} are built from it, so they say the same. + */ +public final class About { + + /** + * What starts the integration. + * + * @param kind the kind of entry: http, timer, rest, or for a consumer the scheme of what it consumes from + * @param label the entry as the sources have it, such as timer:orders (period=500) + * @param route the route it starts + */ + public record In(String kind, String label, String route) { + } + + /** + * A route, in the order to show it: a route that something else in the integration calls comes after its caller, + * one deeper. + * + * @param routeId the route ID (else its key in the analysis) + * @param depth how deep under the route that calls it (0 for a route that nothing in the integration calls) + * @param how call (direct: and the like, the caller waits), async (seda: and the like), or null at depth 0 + * @param from the scheme the route consumes from + * @param steps the number of steps of the route + */ + public record RouteLine(String routeId, int depth, String how, String from, int steps) { + } + + /** + * A system the integration talks to. + * + * @param category the kind of system, as the catalog groups the component + * @param uri the endpoint as the sources have it + * @param direction out, or in/out for a request-reply + * @param route the route that talks to it + */ + public record Out(String category, String uri, String direction, String route) { + } + + /** + * About an integration. + * + * @param purpose what it does, in the words of its author, or null + * @param purposeFrom where the purpose comes from, or null + */ + public record Model(String purpose, String purposeFrom, List<In> in, List<RouteLine> routes, List<Out> out) { + } + + private About() { + } + + /** About the integration in the directory, from the analysis of its sources. */ + public static Model of(Path dir, ProjectOverview.Overview o) { + String[] p = purpose(dir, o); + List<In> in = new ArrayList<>(); + for (ProjectOverview.EntryPoint e : o.entryPoints()) { + // a consumer by the kind of system it consumes from (its scheme) + String kind = "consumer".equals(e.kind()) && e.label().indexOf(':') > 0 + ? e.label().substring(0, e.label().indexOf(':')) : e.kind(); + in.add(new In(kind, e.label(), e.route())); + } + + // the routes that something else in the integration calls go under their caller + Map<String, List<ProjectOverview.Link>> calledBy = new LinkedHashMap<>(); + Set<String> called = new LinkedHashSet<>(); + for (ProjectOverview.Link l : o.links()) { + if (("call".equals(l.kind()) || "async".equals(l.kind())) && !l.from().equals(l.to())) { + calledBy.computeIfAbsent(l.from(), k -> new ArrayList<>()).add(l); + called.add(l.to()); + } + } + List<RouteLine> routes = new ArrayList<>(); + Set<String> shown = new LinkedHashSet<>(); + for (ProjectRoutes.Route r : o.flows()) { + if (!called.contains(r.key())) { + routeLines(o, r, 0, null, calledBy, shown, routes); + } + } + // what is left: routes called only by routes in a cycle + for (ProjectRoutes.Route r : o.flows()) { + if (!shown.contains(r.key())) { + routeLines(o, r, 0, null, calledBy, shown, routes); + } + } + + List<Out> out = new ArrayList<>(); + Set<String> systems = new LinkedHashSet<>(); + for (ProjectOverview.SystemUse s : o.systems()) { + if (!"in".equals(s.direction()) && systems.add(s.uri() + "|" + s.direction())) { + out.add(new Out(s.category(), s.uri(), s.direction(), s.route())); + } + } + return new Model(p != null ? p[0] : null, p != null ? p[1] : null, in, routes, out); + } + + private static void routeLines( + ProjectOverview.Overview o, ProjectRoutes.Route r, int depth, String how, + Map<String, List<ProjectOverview.Link>> calledBy, Set<String> shown, List<RouteLine> lines) { + if (!shown.add(r.key()) || depth > 6) { + return; + } + String from = r.from() != null ? r.from().scheme() : r.kind(); + lines.add(new RouteLine(r.id() != null ? r.id() : r.key(), depth, how, from, r.steps())); + for (ProjectOverview.Link l : calledBy.getOrDefault(r.key(), List.of())) { + ProjectRoutes.Route target = o.route(l.to()); + if (target != null) { + routeLines(o, target, depth + 1, l.kind(), calledBy, shown, lines); + } + } + } + + /** + * What the integration does, in the words of its author: the comment at the top of a route file (the files in the + * order of the analysis), else the descriptions of the routes that something outside starts. Returns the text and + * where it comes from, or null. + */ + public static String[] purpose(Path dir, ProjectOverview.Overview o) { + for (String file : o.files()) { + try { + String text = leadingComment(file, Files.readString(dir.resolve(file), StandardCharsets.UTF_8)); + if (text != null) { + return new String[] { text, "from the comment at the top of " + file }; + } + } catch (IOException e) { + // an unreadable file has no comment + } + } + List<String> descriptions = new ArrayList<>(); + Set<String> entryRoutes = new LinkedHashSet<>(); + o.entryPoints().forEach(e -> entryRoutes.add(e.route())); + for (ProjectRoutes.Route r : o.flows()) { + if (r.hasDescription() && entryRoutes.contains(r.key())) { + descriptions.add(r.description()); + } + } + for (ProjectRoutes.Route r : o.flows()) { + if (r.hasDescription() && !entryRoutes.contains(r.key()) && descriptions.size() < 2) { + descriptions.add(r.description()); + } + } + if (!descriptions.isEmpty()) { + return new String[] { + String.join(" ", descriptions.subList(0, Math.min(2, descriptions.size()))), + "from the descriptions of the routes" }; + } + return null; + } + + /** + * The comment a file starts with: the {@code #} lines of YAML, the Javadoc or {@code //} lines before the class of + * Java, the first {@code <!-- -->} of XML. A license header is no description. + */ + public static String leadingComment(String file, String content) { + String text = null; + if (file.endsWith(".yaml") || file.endsWith(".yml")) { + StringBuilder sb = new StringBuilder(); + for (String line : content.lines().toList()) { + String l = line.strip(); + if (l.startsWith("#")) { + sb.append(l.replaceFirst("^#+\\s?", "")).append(' '); + } else if (!l.isEmpty() || !sb.isEmpty()) { + break; + } + } + text = sb.toString(); + } else if (file.endsWith(".java")) { + int cls = content.indexOf(" class "); + String before = cls > 0 ? content.substring(0, cls) : content; + int doc = before.lastIndexOf("/**"); + if (doc >= 0) { + int end = before.indexOf("*/", doc); + text = end > 0 ? before.substring(doc + 3, end).replaceAll("(?m)^\\s*\\*\\s?", " ") : null; + } else { + StringBuilder sb = new StringBuilder(); + for (String line : before.lines().toList()) { + String l = line.strip(); + if (l.startsWith("//") && !l.startsWith("///") && !l.startsWith("//DEPS") + && !l.startsWith("//JAVA")) { + sb.append(l.substring(2).strip()).append(' '); + } + } + text = sb.toString(); + } + } else if (file.endsWith(".xml")) { + int start = content.indexOf("<!--"); + int end = start >= 0 ? content.indexOf("-->", start) : -1; + text = end > 0 ? content.substring(start + 4, end) : null; + } + if (text == null) { + return null; + } + // a Javadoc tag ends the description + int tag = text.indexOf(" @"); + if (tag > 0) { + text = text.substring(0, tag); + } + text = text.replaceAll("\\s+", " ").strip(); + if (text.isEmpty() || text.contains("Licensed to the Apache") || text.contains("SPDX-License")) { + return null; + } + return text.length() > 300 ? text.substring(0, 297) + "…" : text; + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AboutReport.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AboutReport.java new file mode 100644 index 000000000000..be9caacc2218 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AboutReport.java @@ -0,0 +1,409 @@ +/* + * 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.ai; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.camel.dsl.jbang.core.common.RuntimeHelper; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; + +/** + * About an integration as text and JSON (CAMEL-26700), for {@code camel get about} and the AI tools: {@link About} + * (what it does, from its sources) and, when it runs, what has happened: the exchanges of its routes since it started + * (its status), and the last minutes from the timeline it records (the timeline dev console). + */ +public final class AboutReport { + + private AboutReport() { + } + + /** + * About the integration as JSON. + * + * @param model about it, from its sources + * @param status its status (the status file of the CLI connector), or null when it does not run + * @param timeline its timeline (the timeline dev console), or null + */ + public static JsonObject json(About.Model model, JsonObject status, JsonObject timeline) { + Map<String, JsonObject> routeStatus = routeStatus(status); + JsonObject answer = new JsonObject(); + if (model.purpose() != null) { + answer.put("purpose", model.purpose()); + answer.put("purposeFrom", model.purposeFrom()); + } + JsonArray in = new JsonArray(); + for (About.In e : model.in()) { + JsonObject jo = new JsonObject(); + jo.put("kind", e.kind()); + jo.put("label", e.label()); + jo.put("routeId", e.route()); + putTotal(jo, routeStatus.get(e.route())); + in.add(jo); + } + answer.put("in", in); + JsonArray routes = new JsonArray(); + for (About.RouteLine r : model.routes()) { + JsonObject jo = new JsonObject(); + jo.put("routeId", r.routeId()); + jo.put("depth", r.depth()); + if (r.how() != null) { + jo.put("calledHow", r.how()); + } + jo.put("from", r.from()); + jo.put("steps", r.steps()); + putTotal(jo, routeStatus.get(r.routeId())); + routes.add(jo); + } + answer.put("routes", routes); + JsonArray out = new JsonArray(); + for (About.Out o : model.out()) { + JsonObject jo = new JsonObject(); + jo.put("category", o.category()); + jo.put("uri", o.uri()); + jo.put("direction", o.direction()); + jo.put("routeId", o.route()); + Long hits = sends(status, o.uri(), o.route()); + if (hits != null) { + jo.put("sends", hits); + } + out.add(jo); + } + answer.put("out", out); + if (timeline != null) { + answer.put("lastMinutes", lastMinutes(timeline)); + } + return answer; + } + + /** + * About the integration that runs from the directory, if one does: its pid and name, and {@link #json} with its + * status and timeline; null when none runs from there. + */ + public static JsonObject running(Path dir, About.Model model) { + Path wanted = dir.toAbsolutePath().normalize(); + for (RuntimeHelper.ProcessInfo p : RuntimeHelper.discoverProcesses()) { + JsonObject status = RuntimeHelper.readStatus(p.pid()); + JsonObject runtime = status != null ? (JsonObject) status.get("runtime") : null; + String directory = runtime != null ? runtime.getString("directory") : null; + if (directory == null || !Path.of(directory).toAbsolutePath().normalize().equals(wanted)) { + continue; + } + JsonObject timeline = null; + try { + JsonObject request = new JsonObject(); + request.put("action", "timeline"); + request.put("exchanges", "true"); + timeline = RuntimeHelper.executeAction(p.pid(), request, 5000); + } catch (IOException e) { + // no timeline: the routes since the start only + } + JsonObject answer = json(model, status, timeline != null && timeline.containsKey("routes") ? timeline : null); + answer.put("pid", Long.toString(p.pid())); + answer.put("name", p.name()); + return answer; + } + return null; + } + + /** About the integration as text, from its JSON ({@link #json}). */ + public static String text(String title, JsonObject about) { + StringBuilder sb = new StringBuilder(); + sb.append(title).append('\n'); + if (about.getString("purpose") != null) { + sb.append(about.getString("purpose")).append('\n'); + sb.append(" (").append(about.getString("purposeFrom")).append(")\n"); + } + sb.append("\nIn\n"); + List<JsonObject> in = list(about, "in"); + if (in.isEmpty()) { + sb.append(" nothing from outside\n"); + } + for (JsonObject e : in) { + sb.append(String.format(" %-44s %s%n", e.getString("label"), total(e))); + } + sb.append("\nRoutes\n"); + for (JsonObject r : list(about, "routes")) { + String how = "call".equals(r.getString("calledHow")) ? "↳ " : "async".equals(r.getString("calledHow")) ? "⇢ " : ""; + int steps = r.getIntegerOrDefault("steps", 0); + String line = " ".repeat(r.getIntegerOrDefault("depth", 0)) + how + r.getString("routeId") + " " + + r.getString("from") + " → " + steps + (steps == 1 ? " step" : " steps"); + sb.append(String.format(" %-44s %s%n", line, total(r))); + } + sb.append("\nOut\n"); + List<JsonObject> out = list(about, "out"); + if (out.isEmpty()) { + sb.append(" nothing outside the integration\n"); + } + for (JsonObject o : out) { + sb.append(String.format(" %-44s %s%n", o.getString("uri") + " from " + o.getString("routeId"), + o.get("sends") != null ? o.getLong("sends") + "" : "")); + } + JsonObject last = (JsonObject) about.get("lastMinutes"); + if (last != null) { + sb.append("\nLast ").append(minutes(last.getIntegerOrDefault("seconds", 0))).append('\n'); + sb.append(" ").append(last.getLongOrDefault("exchanges", 0)).append(" exchanges"); + for (String k : List.of("recovered", "handled", "failed")) { + if (last.getLongOrDefault(k, 0) > 0) { + sb.append(" · ").append(last.getLong(k)).append(' ').append(k); + } + } + JsonObject cause = (JsonObject) last.get("cause"); + if (cause != null) { + sb.append(" · ").append(cause.getString("label")).append(" ×").append(cause.getLong("count")) + .append(" (last ").append(ago(cause.getLongOrDefault("lastSecondsAgo", 0))).append(')'); + } + sb.append('\n'); + for (JsonObject r : list(last, "routes")) { + StringBuilder line = new StringBuilder(); + line.append(String.format(" %-20s %6d", r.getString("routeId"), r.getLongOrDefault("exchanges", 0))); + if (r.get("worst") != null) { + line.append(" ").append(r.getLong("worstCount")).append(' ').append(r.getString("worst")) + .append(' ').append(ago(r.getLongOrDefault("worstSecondsAgo", 0))); + if (r.getString("recoveredFrom") != null) { + line.append(" · ").append(r.getString("recoveredFrom")); + } + } + if (r.getLongOrDefault("circuitBreakerOpenSeconds", 0) > 0) { + line.append(" circuit breaker open ").append(r.getLong("circuitBreakerOpenSeconds")).append('s'); + } + if (r.getLongOrDefault("downSeconds", 0) > 0) { + line.append(" down ").append(r.getLong("downSeconds")).append('s'); + } + sb.append(line).append('\n'); + } + } + return sb.toString(); + } + + /** + * What happened in the seconds of the timeline: the exchanges and how they ended, the cause that happened most with + * when it last did, and per route the worst that happened to it with when, the failure a recovery recovered from, + * and the seconds a circuit breaker was open or a chaos rule had it down. + */ + static JsonObject lastMinutes(JsonObject timeline) { + int n = timeline.getIntegerOrDefault("seconds", 0); + JsonObject answer = new JsonObject(); + answer.put("seconds", n); + long exchanges = 0; + Map<String, Long> totals = new LinkedHashMap<>(); + Map<String, long[]> open = new LinkedHashMap<>(); + for (JsonObject cb : list(timeline, "circuitBreakers")) { + long[] states = values(cb, "states", n); + long[] v = open.computeIfAbsent(cb.getString("routeId"), k -> new long[n]); + for (int i = 0; i < n; i++) { + v[i] = Math.max(v[i], states[i]); + } + } + // the failure a recovered exchange of a route was redelivered for, the last one + Map<String, String> recoveredFrom = new LinkedHashMap<>(); + String anyRecoveredFrom = null; + for (JsonObject e : list(timeline, "exchanges")) { + if (e.getBooleanOrDefault("redelivered", false) && !e.getBooleanOrDefault("failed", false) + && !e.getBooleanOrDefault("handled", false) && e.getString("exception") != null) { + recoveredFrom.put(e.getString("routeId"), e.getString("exception")); + anyRecoveredFrom = e.getString("exception"); + } + } + JsonArray routes = new JsonArray(); + for (JsonObject r : list(timeline, "routes")) { + String routeId = r.getString("routeId"); + long[] completed = values(r, "completed", n); + long[] recovered = values(r, "recovered", n); + long[] handled = values(r, "handled", n); + long[] failed = values(r, "failed", n); + long done = sum(completed) + sum(recovered) + sum(handled) + sum(failed); + totals.merge("recovered", sum(recovered), Long::sum); + totals.merge("handled", sum(handled), Long::sum); + totals.merge("failed", sum(failed), Long::sum); + JsonObject jo = new JsonObject(); + jo.put("routeId", routeId); + jo.put("exchanges", done); + long[] worst = sum(failed) > 0 ? failed : sum(handled) > 0 ? handled : sum(recovered) > 0 ? recovered : null; + if (worst != null) { + jo.put("worst", worst == failed ? "failed" : worst == handled ? "handled" : "recovered"); + jo.put("worstCount", sum(worst)); + jo.put("worstSecondsAgo", n - 1 - last(worst)); + if (worst == recovered) { + String from = recoveredFrom.getOrDefault(routeId, anyRecoveredFrom); + if (from != null) { + jo.put("recoveredFrom", from); + } + } + } + long[] states = open.get(routeId); + if (states != null) { + jo.put("circuitBreakerOpenSeconds", Arrays.stream(states).filter(v -> v >= 2).count()); + } + if (r.get("down") != null && sum(values(r, "down", n)) > 0) { + jo.put("downSeconds", sum(values(r, "down", n))); + } + routes.add(jo); + } + for (Object v : timeline.getCollectionOrDefault("exchangesDone", List.of())) { + exchanges += v instanceof Number num ? num.longValue() : 0; + } + answer.put("exchanges", exchanges); + totals.forEach(answer::put); + // the cause that happened most + JsonObject cause = null; + long most = 0; + for (JsonObject e : list(timeline, "errors")) { + long[] counts = values(e, "counts", n); + if (sum(counts) > most) { + most = sum(counts); + String exception = e.getString("exception"); + String label = exception != null ? exception.substring(exception.lastIndexOf('.') + 1) : "error"; + if (e.get("statusCode") != null) { + label += " " + e.getInteger("statusCode"); + } + if (e.getString("chaosRule") != null) { + label = "chaos rule " + e.getString("chaosRule") + ": " + label; + } + cause = new JsonObject(); + cause.put("label", label); + cause.put("routeId", e.getString("routeId")); + cause.put("count", most); + cause.put("lastSecondsAgo", n - 1 - last(counts)); + } + } + if (cause != null) { + answer.put("cause", cause); + } + answer.put("routes", routes); + return answer; + } + + private static Map<String, JsonObject> routeStatus(JsonObject status) { + Map<String, JsonObject> answer = new LinkedHashMap<>(); + if (status != null && status.get("routes") instanceof List<?> routes) { + for (Object o : routes) { + if (o instanceof JsonObject r && r.getString("routeId") != null) { + answer.put(r.getString("routeId"), r); + } + } + } + return answer; + } + + private static void putTotal(JsonObject jo, JsonObject route) { + if (route != null && route.get("statistics") instanceof Map<?, ?> stats) { + if (stats.get("exchangesTotal") instanceof Number total) { + jo.put("exchangesTotal", total.longValue()); + } + if (stats.get("exchangesFailed") instanceof Number failed && failed.longValue() > 0) { + jo.put("exchangesFailed", failed.longValue()); + } + } + } + + /** + * How many times the route sent to the endpoint, from the endpoint statistics in the status; null when there is no + * such endpoint (a URI with a placeholder, or no statistics). + */ + static Long sends(JsonObject status, String uri, String route) { + if (status == null || !(status.get("endpoints") instanceof JsonObject endpoints) + || !(endpoints.get("endpoints") instanceof List<?> list)) { + return null; + } + String base = baseUri(uri); + Long answer = null; + for (Object o : list) { + if (o instanceof JsonObject ep && "out".equals(ep.getString("direction")) + && (route == null || route.equals(ep.getString("routeId"))) && base.equals(baseUri(ep.getString("uri"))) + && ep.get("hits") instanceof Number hits) { + answer = (answer != null ? answer : 0) + hits.longValue(); + } + } + return answer; + } + + /** The URI without its options, and the same with or without the // after the scheme. */ + public static String baseUri(String uri) { + if (uri == null) { + return ""; + } + int q = uri.indexOf('?'); + String answer = q >= 0 ? uri.substring(0, q) : uri; + answer = answer.replaceFirst("^([a-zA-Z0-9+.-]+):/{0,2}", "$1:"); + return answer.endsWith("/") ? answer.substring(0, answer.length() - 1) : answer; + } + + private static String total(JsonObject jo) { + if (jo.get("exchangesTotal") == null) { + return ""; + } + return jo.getLong("exchangesTotal") + + (jo.get("exchangesFailed") != null ? " (" + jo.getLong("exchangesFailed") + " failed)" : ""); + } + + private static String minutes(int seconds) { + return seconds % 60 == 0 ? seconds / 60 + "m" : seconds + "s"; + } + + private static String ago(long seconds) { + if (seconds <= 0) { + return "now"; + } + return seconds < 60 ? seconds + "s ago" : seconds / 60 + "m" + (seconds % 60 > 0 ? seconds % 60 + "s" : "") + " ago"; + } + + private static List<JsonObject> list(JsonObject jo, String key) { + List<JsonObject> answer = new ArrayList<>(); + if (jo.get(key) instanceof List<?> l) { + for (Object o : l) { + if (o instanceof JsonObject e) { + answer.add(e); + } + } + } + return answer; + } + + private static long[] values(JsonObject jo, String key, int n) { + long[] answer = new long[n]; + if (jo.get(key) instanceof List<?> l) { + for (int i = 0; i < Math.min(n, l.size()); i++) { + answer[i] = l.get(i) instanceof Number num ? num.longValue() : 0; + } + } + return answer; + } + + private static long sum(long[] values) { + long sum = 0; + for (long v : values) { + sum += Math.max(0, v); + } + return sum; + } + + private static int last(long[] values) { + for (int i = values.length - 1; i >= 0; i--) { + if (values[i] > 0) { + return i; + } + } + return values.length - 1; + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java index 1b0d5319ceb3..c607a02e3178 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java @@ -467,7 +467,8 @@ public final class AuthoringTools { registry.accept(tool("camel_project_overview", "Overview of a project's integrations from its route sources, no running integration needed: " + "routes, entry points, links between routes, external systems, " - + "findings, and the AI-assisted parts of its " + + "findings, its purpose, what happened when it runs (running), " + + "and the AI-assisted parts of its " + IntegrationSummary.FILE_NAME + " (fields starting with ai).") .param("directory", "string", DIRECTORY_DESC, false) .param("camelVersion", "string", VERSION_DESC, false) @@ -477,6 +478,16 @@ public final class AuthoringTools { ProjectOverview.Overview overview = ProjectOverview.analyze(dir, ctx.catalog()); IntegrationSummary.Summary summary = IntegrationSummary.read(dir); JsonObject result = ProjectOverview.toJson(overview, summary); + // what it does in the words of its author, and what has happened when it runs (CAMEL-26700) + About.Model about = About.of(dir, overview); + if (about.purpose() != null) { + result.put("purpose", about.purpose()); + result.put("purposeFrom", about.purposeFrom()); + } + JsonObject running = AboutReport.running(dir, about); + if (running != null) { + result.put("running", running); + } if (summary == null || !overview.fingerprint().equals(summary.fingerprint())) { result.put("hint", "To explain the project, write the overview, capabilities, the missing" + " descriptions and labels for the decisions with camel_save_project_summary"); diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListAbout.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListAbout.java new file mode 100644 index 000000000000..da6447fe8e01 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListAbout.java @@ -0,0 +1,118 @@ +/* + * 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.process; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.apache.camel.catalog.CamelCatalog; +import org.apache.camel.catalog.DefaultCamelCatalog; +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.dsl.jbang.core.commands.ai.About; +import org.apache.camel.dsl.jbang.core.commands.ai.AboutReport; +import org.apache.camel.dsl.jbang.core.commands.ai.ProjectOverview; +import org.apache.camel.dsl.jbang.core.common.ActionClient; +import org.apache.camel.dsl.jbang.core.common.ProcessHelper; +import org.apache.camel.util.TimeUtils; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import picocli.CommandLine; +import picocli.CommandLine.Command; + +@Command(name = "about", + description = "Get what an integration does and what has happened: its purpose, what comes in, its routes, what goes out, and the last minutes", + sortOptions = false, showDefaultValues = true, + footer = { + "%nThe purpose is the comment at the top of the main source file, else the descriptions of the routes.", + "The last minutes come from the timeline the integration records (the timeline dev console, on in profile dev).", + "%nExamples:", + " camel get about", + " camel get about myapp --json" }) +public class ListAbout extends ProcessWatchCommand { + + @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1") + String name = "*"; + + private CamelCatalog catalog; + + public ListAbout(CamelJBangMain main) { + super(main); + } + + @Override + public Integer doProcessWatchCall() throws Exception { + JsonArray all = new JsonArray(); + StringBuilder text = new StringBuilder(); + forEachIntegration(name, (ph, root) -> { + JsonObject context = (JsonObject) root.get("context"); + if (context == null) { + return; + } + String integrationName = context.getString("name"); + if ("CamelJBang".equals(integrationName)) { + integrationName = ProcessHelper.extractName(root, ph); + } + JsonObject runtime = (JsonObject) root.get("runtime"); + String directory = runtime != null ? runtime.getString("directory") : null; + if (directory == null || !Files.isDirectory(Path.of(directory))) { + printer().printWarn("Skipped " + integrationName + ": its source directory is not known"); + return; + } + Path dir = Path.of(directory); + About.Model model = About.of(dir, ProjectOverview.analyze(dir, catalog())); + JsonObject about = AboutReport.json(model, root, loadTimeline(ph.pid())); + about.put("pid", Long.toString(ph.pid())); + about.put("name", integrationName); + about.put("camelVersion", context.getString("version")); + about.put("directory", directory); + all.add(about); + if (!text.isEmpty()) { + text.append('\n'); + } + String title = integrationName + " (pid " + ph.pid() + ")" + " · Camel " + context.getString("version") + + " · up " + TimeUtils.printSince(extractSince(ph)); + text.append(AboutReport.text(title, about)); + }); + if (jsonOutput) { + printer().println(all.size() == 1 ? ((JsonObject) all.get(0)).toJson() : all.toJson()); + } else if (!text.isEmpty()) { + printer().print(text.toString()); + } + return 0; + } + + private CamelCatalog catalog() { + if (catalog == null) { + catalog = new DefaultCamelCatalog(); + } + return catalog; + } + + /** Asks the integration for the timeline it records (with the exchanges, for what a recovery recovered from). */ + JsonObject loadTimeline(long pid) { + JsonObject request = new JsonObject(); + request.put("action", "timeline"); + request.put("exchanges", "true"); + try { + ActionClient.ActionResult result = ActionClient.execute(pid, request, ActionClient.DEFAULT_TIMEOUT); + return result.isOk() && result.reply() != null && result.reply().containsKey("routes") ? result.reply() : null; + } catch (IOException e) { + return null; + } + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AboutTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AboutTest.java new file mode 100644 index 000000000000..0d7f7c8593ff --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AboutTest.java @@ -0,0 +1,139 @@ +/* + * 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.ai; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.apache.camel.catalog.DefaultCamelCatalog; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +class AboutTest { + + private static final DefaultCamelCatalog CATALOG = new DefaultCamelCatalog(); + + @TempDir + Path dir; + + private About.Model shop() throws Exception { + Files.writeString(dir.resolve("shop.camel.yaml"), """ + # Every order is checked against the stock and shipped. + - route: + id: orders + from: + uri: timer:orders + parameters: + period: 500 + steps: + - to: direct:stock + - to: seda:ship + - to: http://localhost:8765/pay + - route: + id: stock + from: + uri: direct:stock + steps: + - log: checked + - route: + id: ship + from: + uri: seda:ship + steps: + - log: shipped + """); + return About.of(dir, ProjectOverview.analyze(dir, CATALOG)); + } + + @Test + void whatComesInTheRoutesUnderTheirCallerAndWhatGoesOut() throws Exception { + About.Model m = shop(); + assertThat(m.purpose()).isEqualTo("Every order is checked against the stock and shipped."); + assertThat(m.purposeFrom()).isEqualTo("from the comment at the top of shop.camel.yaml"); + assertThat(m.in()).extracting(About.In::route).containsExactly("orders"); + assertThat(m.in().get(0).kind()).isEqualTo("schedule"); + assertThat(m.routes()).containsExactly( + new About.RouteLine("orders", 0, null, "timer", 3), + new About.RouteLine("stock", 1, "call", "direct", 1), + new About.RouteLine("ship", 1, "async", "seda", 1)); + assertThat(m.out()).singleElement().satisfies(o -> { + assertThat(o.uri()).contains("localhost:8765/pay"); + assertThat(o.route()).isEqualTo("orders"); + }); + } + + @Test + void thePurposeIsTheCommentAtTheTopOfTheFile() { + assertThat(About.leadingComment("a.yaml", "# Licensed to the Apache Software Foundation (ASF)\n- route:")).isNull(); + assertThat(About.leadingComment("Shop.java", "/** Takes the orders. */\npublic class Shop {}")) + .isEqualTo("Takes the orders."); + } + + @Test + void whatHasHappenedFromTheStatusAndTheTimeline() throws Exception { + About.Model m = shop(); + JsonObject status = (JsonObject) Jsoner.deserialize(""" + {"routes": [{"routeId": "orders", "statistics": {"exchangesTotal": 120, "exchangesFailed": 2}}], + "endpoints": {"endpoints": [ + {"uri": "http://localhost:8765/pay", "direction": "out", "routeId": "orders", "hits": 118}]}} + """); + JsonObject timeline = (JsonObject) Jsoner.deserialize(""" + {"end": 1800000000, "seconds": 4, "exchangesDone": [2, 2, 2, 2], + "routes": [ + {"routeId": "orders", "completed": [2, 2, 1, 2], "recovered": [0, 0, 1, 0], + "handled": [0, 0, 0, 0], "failed": [0, 0, 0, 0]}, + {"routeId": "stock", "completed": [2, 2, 2, 2], "recovered": [0, 0, 0, 0], + "handled": [0, 0, 0, 0], "failed": [0, 0, 0, 0]}], + "errors": [{"routeId": "orders", "exception": "java.net.ConnectException", "counts": [0, 0, 1, 0]}], + "circuitBreakers": [{"routeId": "stock", "id": "cb1", "kind": "resilience4j", "states": [2, 2, 1, 0]}], + "exchanges": [{"exchangeId": "A", "routeId": "orders", "redelivered": true, "failed": false, + "handled": false, "exception": "Connection refused"}]} + """); + JsonObject about = AboutReport.json(m, status, timeline); + JsonObject in = (JsonObject) ((List<?>) about.get("in")).get(0); + assertThat(in.getLong("exchangesTotal")).isEqualTo(120); + JsonObject out = (JsonObject) ((List<?>) about.get("out")).get(0); + assertThat(out.getLong("sends")).isEqualTo(118); + + JsonObject last = (JsonObject) about.get("lastMinutes"); + assertThat(last.getLong("exchanges")).isEqualTo(8); + assertThat(last.getLong("recovered")).isEqualTo(1); + JsonObject cause = (JsonObject) last.get("cause"); + assertThat(cause.getString("label")).isEqualTo("ConnectException"); + assertThat(cause.getLong("lastSecondsAgo")).isEqualTo(1); + + String text = AboutReport.text("shop (pid 1)", about); + assertThat(text).contains("Every order is checked against the stock and shipped.") + .contains("timer:orders").contains("120 (2 failed)") + .contains(" ↳ stock direct → 1 step").contains(" ⇢ ship seda → 1 step") + .contains("localhost:8765/pay from orders").contains("118") + .contains("Last 4s").contains("8 exchanges · 1 recovered · ConnectException ×1 (last 1s ago)") + .contains("1 recovered 1s ago · Connection refused") + .contains("circuit breaker open 2s"); + } + + @Test + void notRunning() throws Exception { + String text = AboutReport.text("shop", AboutReport.json(shop(), null, null)); + assertThat(text).contains("Routes").doesNotContain("Last "); + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/ListAboutTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/ListAboutTest.java new file mode 100644 index 000000000000..2148ddece69a --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/ListAboutTest.java @@ -0,0 +1,111 @@ +/* + * 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.process; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mockStatic; + +@ExtendWith(MockitoExtension.class) +class ListAboutTest extends ProcessCommandTestSupport { + + @TempDir + Path project; + + @Test + void whatTheIntegrationDoesAndWhatHasHappened() throws Exception { + String out = run(command(timeline()), false); + assertThat(out).contains("myApp (pid " + TEST_PID + ")") + .contains("Every order is checked against the stock.") + .contains("In").contains("timer:orders") + .contains("Routes").contains("orders timer → 1 step").contains(" ↳ stock direct → 1 step") + .contains("Last 3s").contains("6 exchanges"); + } + + @Test + void asJson() throws Exception { + JsonObject about = (JsonObject) Jsoner.deserialize(run(command(null), true)); + assertThat(about.getString("name")).isEqualTo("myApp"); + assertThat(about.getString("purpose")).isEqualTo("Every order is checked against the stock."); + assertThat((List<?>) about.get("routes")).hasSize(2); + // not recording a timeline: no last minutes + assertThat(about.containsKey("lastMinutes")).isFalse(); + } + + private String run(ListAbout command, boolean json) throws Exception { + Files.writeString(project.resolve("shop.camel.yaml"), """ + # Every order is checked against the stock. + - route: + id: orders + from: + uri: timer:orders + steps: + - to: direct:stock + - route: + id: stock + from: + uri: direct:stock + steps: + - log: checked + """); + JsonObject status = buildContextStatus("myApp", 5); + JsonObject runtime = new JsonObject(); + runtime.put("directory", project.toString()); + status.put("runtime", runtime); + writeStatusFile(TEST_PID, status); + command.jsonOutput = json; + try (MockedStatic<ProcessHandle> mocked = mockStatic(ProcessHandle.class)) { + ProcessHandle ph = mockProcessHandle(TEST_PID); + ProcessHandle currentHandle = mockCurrentHandle(); + mocked.when(ProcessHandle::current).thenReturn(currentHandle); + mocked.when(ProcessHandle::allProcesses).thenAnswer(inv -> Stream.of(ph)); + + assertThat(command.doCall()).isZero(); + return printer.getOutput().trim(); + } + } + + private ListAbout command(JsonObject timeline) { + return new ListAbout(new CamelJBangMain().withPrinter(printer)) { + @Override + JsonObject loadTimeline(long pid) { + return timeline; + } + }; + } + + private static JsonObject timeline() throws Exception { + return (JsonObject) Jsoner.deserialize(""" + {"end": 1800000000, "seconds": 3, "exchangesDone": [2, 2, 2], + "routes": [{"routeId": "orders", "completed": [2, 2, 2], "recovered": [0, 0, 0], + "handled": [0, 0, 0], "failed": [0, 0, 0]}]} + """); + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/resources/golden/cli-help.txt b/dsl/camel-jbang/camel-jbang-core/src/test/resources/golden/cli-help.txt index 1f716380f9dc..78c60e9522c2 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/resources/golden/cli-help.txt +++ b/dsl/camel-jbang/camel-jbang-core/src/test/resources/golden/cli-help.txt @@ -3337,6 +3337,9 @@ Commands: startup-recorder Display startup recording timeline Get what the routes did per second, the errors, and the failed and slowest exchanges + about Get what an integration does and what has happened: its + purpose, what comes in, its routes, what goes out, and + the last minutes transformer Get list of data type transformers variable List variables of Camel integrations vault List secrets from security vaults @@ -3347,6 +3350,29 @@ Examples: camel get health camel get endpoint +== camel get about --help +Usage: camel get about [-h] [--json] [--watch] [--duration=<duration>] [<name>] +Get what an integration does and what has happened: its purpose, what comes in, +its routes, what goes out, and the last minutes + [<name>] Name or pid of running Camel integration + Default: * + -h, --help Display the help and sub-commands + --watch Execute periodically and showing output fullscreen + --json Output in JSON Format + --duration=<duration> + How long to keep watching or following, in millis or as a + duration such as 30s (default: until Enter is pressed) + Default: 0 + +The purpose is the comment at the top of the main source file, else the +descriptions of the routes. +The last minutes come from the timeline the integration records (the timeline +dev console, on in profile dev). + +Examples: + camel get about + camel get about myapp --json + == camel get bean --help Usage: camel get bean [-h] [--dsl] [--internal] [--json] [--nulls[=<nulls>]] [--properties[=<properties>]] [--filter=<filter>] diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java index c74a2fcc259b..ab7d43abb816 100644 --- a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java +++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java @@ -473,9 +473,11 @@ public class AuthoringTools { description = "A high-level overview of a project's integrations from its route sources (no running " + "integration needed): routes, entry points, how routes connect (call, hand-off, event), " + "external systems by category, findings (missing routes, cycles, routes without a " - + "description), and the AI-assisted parts of its camel-summary.md (fields starting " - + "with ai). Use it to explain a project, then camel_save_project_summary to keep the " - + "explanation.") + + "description), the purpose (the comment at the top of the main file), and the " + + "AI-assisted parts of its camel-summary.md (fields starting with ai). When the project " + + "runs, running has what happened: the exchanges per route and endpoint since the start, " + + "and the last minutes (lastMinutes). Use it to explain a project, then " + + "camel_save_project_summary to keep the explanation.") public JsonObject camel_project_overview( @ToolArg(description = DIRECTORY_DESC, required = false) String directory, @ToolArg(description = VERSION_DESC, required = false) String camelVersion) { 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 77376ba71cc5..05850be553ee 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 @@ -16,14 +16,12 @@ */ package org.apache.camel.dsl.jbang.core.commands.tui; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Arrays; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -45,8 +43,9 @@ import dev.tamboui.widgets.block.Block; import dev.tamboui.widgets.block.BorderType; import dev.tamboui.widgets.block.Borders; import dev.tamboui.widgets.paragraph.Paragraph; +import org.apache.camel.dsl.jbang.core.commands.ai.About; +import org.apache.camel.dsl.jbang.core.commands.ai.AboutReport; import org.apache.camel.dsl.jbang.core.commands.ai.ProjectOverview; -import org.apache.camel.dsl.jbang.core.commands.ai.ProjectRoutes; import org.apache.camel.util.TimeUtils; /** @@ -64,10 +63,13 @@ final class AboutView { private static final String BLOCKS = " ▁▂▃▄▅▆▇█"; // the strips have this many columns, whatever the zoom private static final int STRIP_COLUMNS = 60; + // the sparklines of In and Out + private static final int SPARK_WIDTH = 14; private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault()); private final MonitorContext ctx; private volatile ProjectOverview.Overview overview; + private volatile About.Model model; private volatile String purpose; private volatile String purposeFrom; private volatile boolean loading; @@ -118,11 +120,12 @@ final class AboutView { Thread t = new Thread(() -> { try { ProjectOverview.Overview o = ProjectOverview.analyze(d, ArchitectureView.catalog()); - String[] p = purpose(d, o); + About.Model m = About.of(d, o); onRenderThread.accept(() -> { overview = o; - purpose = p != null ? p[0] : null; - purposeFrom = p != null ? p[1] : null; + model = m; + purpose = m.purpose(); + purposeFrom = m.purposeFrom(); error = o.flows().isEmpty() ? "No routes found in " + d : null; loading = false; }); @@ -267,7 +270,10 @@ final class AboutView { List<Target> routeTargets = new ArrayList<>(); List<Target> outTargets = new ArrayList<>(); if (o != null) { - columns(o, info, in, routes, out, inTargets, routeTargets, outTargets); + About.Model m = model; + if (m != null) { + columns(m, info, in, routes, out, inTargets, routeTargets, outTargets); + } } // the cursor goes down In, then Routes, then Out List<Target> all = new ArrayList<>(); @@ -505,8 +511,8 @@ final class AboutView { } } - private static void columns( - ProjectOverview.Overview o, IntegrationInfo info, List<Line> in, List<Line> routes, List<Line> out, + private void columns( + About.Model m, IntegrationInfo info, List<Line> in, List<Line> routes, List<Line> out, List<Target> inTargets, List<Target> routeTargets, List<Target> outTargets) { Map<String, Long> totals = new LinkedHashMap<>(); for (RouteInfo r : info.routes) { @@ -514,62 +520,99 @@ final class AboutView { totals.put(r.routeId, r.total); } } - for (ProjectOverview.EntryPoint e : o.entryPoints()) { + for (About.In e : m.in()) { Long total = e.route() != null ? totals.get(e.route()) : null; - // a consumer by the kind of system it consumes from (its scheme) - String kind = "consumer".equals(e.kind()) && e.label().indexOf(':') > 0 - ? e.label().substring(0, e.label().indexOf(':')) : e.kind(); - in.add(Line.from(Span.raw(" " + icon(kind) + " "), Span.styled(e.label(), Style.EMPTY.bold()), + in.add(Line.from(Span.raw(" " + icon(e.kind()) + " "), Span.styled(e.label(), Style.EMPTY.bold()), Span.styled(total != null ? " " + total : "", Theme.success()))); inTargets.add(e.route() != null ? new Target(e.route(), null) : null); + // the exchanges of the route it starts over the last minutes, and the rate now + long[] perSecond = e.route() != null ? routePerSecond(info, e.route()) : null; + if (perSecond != null) { + in.add(sparkLine(" ", perSecond)); + inTargets.add(null); + } } if (in.isEmpty()) { in.add(Line.from(Span.styled(" nothing from outside", Theme.muted()))); inTargets.add(null); } - // the routes that something else in the integration calls go under their caller - Map<String, List<ProjectOverview.Link>> calledBy = new LinkedHashMap<>(); - Set<String> called = new LinkedHashSet<>(); - for (ProjectOverview.Link l : o.links()) { - if (("call".equals(l.kind()) || "async".equals(l.kind())) && !l.from().equals(l.to())) { - calledBy.computeIfAbsent(l.from(), k -> new ArrayList<>()).add(l); - called.add(l.to()); - } + for (About.RouteLine r : m.routes()) { + List<Span> spans = new ArrayList<>(); + // ↳ a call that waits for the route, ⇢ a hand-off that does not + 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"), + Theme.muted())); + Long total = totals.get(r.routeId()); + if (total != null) { + spans.add(Span.styled(" " + total, Theme.success())); + } + routes.add(Line.from(spans)); + routeTargets.add(new Target(r.routeId(), null)); + } + + for (About.Out s : m.out()) { + Long hits = outHits(info, s.uri(), s.route()); + out.add(Line.from(Span.raw(" " + icon(s.category()) + " "), Span.styled(shortUri(s.uri()), Style.EMPTY.bold()), + Span.styled(hits != null ? " " + hits : "", Theme.success()))); + outTargets.add(new Target(s.route(), s.uri())); + List<Span> from = new ArrayList<>(); + from.add(Span.styled(" from " + s.route(), Theme.muted())); + // the exchanges of the route that sends to it, as In has them: the integration records the last + // 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).spans()); + } + out.add(Line.from(from)); + outTargets.add(null); } - Set<String> shown = new LinkedHashSet<>(); - for (ProjectRoutes.Route r : o.flows()) { - if (!called.contains(r.key())) { - routeLines(o, r, 0, null, calledBy, totals, shown, routes, routeTargets); - } + if (out.isEmpty()) { + out.add(Line.from(Span.styled(" nothing outside the integration", Theme.muted()))); + outTargets.add(null); + } + } + + /** The exchanges done per second by the route over What happened, or null when nothing is recorded. */ + private long[] routePerSecond(IntegrationInfo info, String routeId) { + long end = ctx.timeline.endSecond(info.pid); + if (end < 0) { + return null; } - // what is left: routes called only by routes in a cycle - for (ProjectRoutes.Route r : o.flows()) { - if (!shown.contains(r.key())) { - routeLines(o, r, 0, null, calledBy, totals, shown, routes, routeTargets); + List<Timeline.RouteSeries> series = ctx.timeline.routes(info.pid, List.of(routeId), end, HAPPENED_SECONDS); + for (Timeline.RouteSeries r : series) { + if (routeId.equals(r.route())) { + long[] done = new long[HAPPENED_SECONDS]; + for (int i = 0; i < done.length; i++) { + done[i] = Math.max(0, r.completed()[i]) + Math.max(0, r.recovered()[i]) + Math.max(0, r.handled()[i]) + + Math.max(0, r.failed()[i]); + } + return done; } } + return null; + } - Set<String> systems = new LinkedHashSet<>(); - for (ProjectOverview.SystemUse s : o.systems()) { - if ("in".equals(s.direction())) { - continue; - } - String key = s.uri() + "|" + s.direction(); - if (systems.add(key)) { - Long hits = outHits(info, s.uri(), s.route()); - out.add(Line.from(Span.raw(" " + icon(s.category()) + " "), Span.styled(shortUri(s.uri()), Style.EMPTY.bold()), - Span.styled(" " + s.direction(), Theme.muted()), - Span.styled(hits != null ? " " + hits : "", Theme.success()))); - outTargets.add(new Target(s.route(), s.uri())); - out.add(Line.from(Span.styled(" from " + s.route(), Theme.muted()))); - outTargets.add(null); - } + /** The values as a sparkline and the rate now, or only idle when there was nothing. */ + private static Line sparkLine(String indent, long[] values) { + if (Arrays.stream(values).allMatch(v -> v <= 0)) { + return Line.from(Span.raw(indent), Span.styled("idle", Theme.muted())); } - if (out.isEmpty()) { - out.add(Line.from(Span.styled(" nothing outside the integration", Theme.muted()))); - outTargets.add(null); + return Line.from(Span.raw(indent), Span.styled(spark(values, SPARK_WIDTH), Theme.info()), + Span.styled(" " + rate(values), Theme.muted())); + } + + /** The rate now: the mean of the last 10 seconds, or idle. */ + static String rate(long[] values) { + int n = Math.min(10, values.length); + long sum = 0; + for (int i = values.length - n; i < values.length; i++) { + sum += Math.max(0, values[i]); } + double r = n > 0 ? (double) sum / n : 0; + return r == 0 ? "idle" : r >= 10 ? String.format(Locale.ROOT, "%.0f/s", r) : String.format(Locale.ROOT, "%.1f/s", r); } /** @@ -590,41 +633,7 @@ final class AboutView { /** The URI without its options, and the same with or without the // after the scheme. */ static String baseUri(String uri) { - if (uri == null) { - return ""; - } - int q = uri.indexOf('?'); - String answer = q >= 0 ? uri.substring(0, q) : uri; - answer = answer.replaceFirst("^([a-zA-Z0-9+.-]+):/{0,2}", "$1:"); - return answer.endsWith("/") ? answer.substring(0, answer.length() - 1) : answer; - } - - private static void routeLines( - ProjectOverview.Overview o, ProjectRoutes.Route r, int depth, String how, - Map<String, List<ProjectOverview.Link>> calledBy, Map<String, Long> totals, Set<String> shown, - List<Line> lines, List<Target> targets) { - if (!shown.add(r.key()) || depth > 6) { - return; - } - List<Span> spans = new ArrayList<>(); - spans.add(Span.raw(" " + " ".repeat(depth) + (how != null ? how + " " : ""))); - spans.add(Span.styled(r.id() != null ? r.id() : r.key(), Style.EMPTY.bold())); - String from = r.from() != null ? r.from().scheme() : r.kind(); - spans.add(Span.styled(" " + from + " → " + r.steps() + (r.steps() == 1 ? " step" : " steps"), Theme.muted())); - Long total = totals.get(r.id()); - if (total != null) { - spans.add(Span.styled(" " + total, Theme.success())); - } - lines.add(Line.from(spans)); - targets.add(new Target(r.id() != null ? r.id() : r.key(), null)); - for (ProjectOverview.Link l : calledBy.getOrDefault(r.key(), List.of())) { - ProjectRoutes.Route target = o.route(l.to()); - if (target != null) { - // ↳ a call that waits for the route, ⇢ a hand-off that does not - routeLines(o, target, depth + 1, "call".equals(l.kind()) ? "↳" : "⇢", calledBy, totals, shown, lines, - targets); - } - } + return AboutReport.baseUri(uri); } private static String icon(String kind) { @@ -807,98 +816,6 @@ final class AboutView { // ---- the purpose line ---- - /** - * What the integration does, in the words of its author: the comment at the top of a route file (the files in the - * order of the analysis), else the descriptions of the routes that something outside starts. Returns the text and - * where it comes from, or null. - */ - static String[] purpose(Path dir, ProjectOverview.Overview o) { - for (String file : o.files()) { - try { - String text = leadingComment(file, Files.readString(dir.resolve(file), StandardCharsets.UTF_8)); - if (text != null) { - return new String[] { text, "from the comment at the top of " + file }; - } - } catch (IOException e) { - // an unreadable file has no comment - } - } - List<String> descriptions = new ArrayList<>(); - Set<String> entryRoutes = new LinkedHashSet<>(); - o.entryPoints().forEach(e -> entryRoutes.add(e.route())); - for (ProjectRoutes.Route r : o.flows()) { - if (r.hasDescription() && entryRoutes.contains(r.key())) { - descriptions.add(r.description()); - } - } - for (ProjectRoutes.Route r : o.flows()) { - if (r.hasDescription() && !entryRoutes.contains(r.key()) && descriptions.size() < 2) { - descriptions.add(r.description()); - } - } - if (!descriptions.isEmpty()) { - return new String[] { - String.join(" ", descriptions.subList(0, Math.min(2, descriptions.size()))), - "from the descriptions of the routes" }; - } - return null; - } - - /** - * The comment a file starts with: the {@code #} lines of YAML, the Javadoc or {@code //} lines before the class of - * Java, the first {@code <!-- -->} of XML. A license header is no description. - */ - static String leadingComment(String file, String content) { - String text = null; - if (file.endsWith(".yaml") || file.endsWith(".yml")) { - StringBuilder sb = new StringBuilder(); - for (String line : content.lines().toList()) { - String l = line.strip(); - if (l.startsWith("#")) { - sb.append(l.replaceFirst("^#+\\s?", "")).append(' '); - } else if (!l.isEmpty() || !sb.isEmpty()) { - break; - } - } - text = sb.toString(); - } else if (file.endsWith(".java")) { - int cls = content.indexOf(" class "); - String before = cls > 0 ? content.substring(0, cls) : content; - int doc = before.lastIndexOf("/**"); - if (doc >= 0) { - int end = before.indexOf("*/", doc); - text = end > 0 ? before.substring(doc + 3, end).replaceAll("(?m)^\\s*\\*\\s?", " ") : null; - } else { - StringBuilder sb = new StringBuilder(); - for (String line : before.lines().toList()) { - String l = line.strip(); - if (l.startsWith("//") && !l.startsWith("///") && !l.startsWith("//DEPS") - && !l.startsWith("//JAVA")) { - sb.append(l.substring(2).strip()).append(' '); - } - } - text = sb.toString(); - } - } else if (file.endsWith(".xml")) { - int start = content.indexOf("<!--"); - int end = start >= 0 ? content.indexOf("-->", start) : -1; - text = end > 0 ? content.substring(start + 4, end) : null; - } - if (text == null) { - return null; - } - // a Javadoc tag ends the description - int tag = text.indexOf(" @"); - if (tag > 0) { - text = text.substring(0, tag); - } - text = text.replaceAll("\\s+", " ").strip(); - if (text.isEmpty() || text.contains("Licensed to the Apache") || text.contains("SPDX-License")) { - return null; - } - return text.length() > 300 ? text.substring(0, 297) + "…" : text; - } - static List<String> wrap(String text, int width, int maxLines) { List<String> lines = new ArrayList<>(); StringBuilder sb = new StringBuilder(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutViewTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutViewTest.java index adb4bdb54da3..be7349c1b680 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutViewTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutViewTest.java @@ -31,6 +31,7 @@ import dev.tamboui.layout.Rect; import dev.tamboui.terminal.Frame; import dev.tamboui.tui.event.KeyCode; import dev.tamboui.tui.event.KeyEvent; +import org.apache.camel.dsl.jbang.core.commands.ai.About; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -56,14 +57,14 @@ class AboutViewTest { @Test void thePurposeIsTheCommentAtTheTopOfTheFile() { - assertThat(AboutView.leadingComment("orders.camel.yaml", """ + assertThat(About.leadingComment("orders.camel.yaml", """ # Each order id is accepted once. # An accepted order is enriched with its customer. - route: id: orders """)).isEqualTo("Each order id is accepted once. An accepted order is enriched with its customer."); - assertThat(AboutView.leadingComment("Shop.java", """ + assertThat(About.leadingComment("Shop.java", """ //DEPS org.apache.camel:camel-http import org.apache.camel.builder.RouteBuilder; @@ -74,12 +75,12 @@ class AboutViewTest { */ public class Shop extends RouteBuilder { """)).isEqualTo("A small shop: every order is reserved and paid."); - assertThat(AboutView.leadingComment("routes.xml", "<?xml version=\"1.0\"?>\n<!-- Copies the files. -->\n<routes/>")) + assertThat(About.leadingComment("routes.xml", "<?xml version=\"1.0\"?>\n<!-- Copies the files. -->\n<routes/>")) .isEqualTo("Copies the files."); // a license is no description - assertThat(AboutView.leadingComment("a.yaml", "# Licensed to the Apache Software Foundation (ASF)\n- route:")) + assertThat(About.leadingComment("a.yaml", "# Licensed to the Apache Software Foundation (ASF)\n- route:")) .isNull(); - assertThat(AboutView.leadingComment("a.yaml", "- route:\n id: a")).isNull(); + assertThat(About.leadingComment("a.yaml", "- route:\n id: a")).isNull(); } @Test @@ -232,6 +233,10 @@ class AboutViewTest { assertThat(screen).contains("Last 5m").contains("30 handled") .contains("HttpOperationFailedException 503 ×30").contains("Watch").contains("Next"); assertThat(screen).contains("What happened").contains("30 handled"); + // the sparklines of In (the route it starts) and Out (the route that sends), with the rate now + assertThat(screen).contains("2.0/s").contains("from orders "); + assertThat(screen.lines().filter(l -> l.contains("from orders")).findFirst().orElseThrow()) + .contains("2.0/s"); }); // the cursor goes down In, Routes and Out; Enter opens what it is on
