This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch tui-walk-examples in repository https://gitbox.apache.org/repos/asf/camel.git
commit 55da89e6cd063a6b3665d2f54683f3d1576eb7f7 Author: Claus Ibsen <[email protected]> AuthorDate: Mon Nov 9 23:59:01 2026 +0100 chore: camel-jbang - About says what an integration does from its README too, opens the README and the summary, and shows the period of a timer From a walk of the quick-start and run examples in the TUI: - About: without a comment at the top of a route file, the purpose is the first paragraph of the README, then the descriptions of the routes, then the overview of camel-summary.md (AI-assisted). The four quick-start examples, memory-leak and tui-hello-world had none. r opens the README and s the summary of the project in the viewer. - About: In shows the period or schedule a timer or cron runs with, from the properties of the integration with its profile applied, not {{welcome.period}}. The hint names the file of the first route, not beans.yaml. - The summary is named as the integration, not the directory an example runs from (camel-example-7061...). - The shell, the AI panel and the MCP answers get the full name of the selected integration; it was shortened to 20 characters for the title, so camel commands in the shell missed properties-and-profiles. - Overview: no 0ms/0ms/0ms before the first exchange. - The CPU average of an integration starts at 0, so the start of its JVM is no 500% average of the last 15 minutes. - The diagram zoom is saved one at a time, and AbstractDiagramTabTest no longer writes into the settings of who runs it (it failed now and then on the order of the background saves). - ExamplesSayWhatTheyDoTest: every bundled example says what it does. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../ROOT/pages/camel-jbang-tui-observe.adoc | 10 ++- .../camel/dsl/jbang/core/commands/ai/About.java | 89 +++++++++++++++++++++- .../dsl/jbang/core/commands/ai/AboutTest.java | 69 +++++++++++++++++ .../commands/ai/ExamplesSayWhatTheyDoTest.java | 60 +++++++++++++++ .../dsl/jbang/core/commands/tui/AboutView.java | 76 ++++++++++++++---- .../camel/dsl/jbang/core/commands/tui/AiPanel.java | 2 +- .../core/commands/tui/IntegrationSummaryDoc.java | 20 ++++- .../camel/dsl/jbang/core/commands/tui/LoadAvg.java | 18 ++++- .../dsl/jbang/core/commands/tui/McpFacade.java | 2 +- .../jbang/core/commands/tui/MetricsCollector.java | 2 +- .../jbang/core/commands/tui/MonitorContext.java | 14 ++++ .../dsl/jbang/core/commands/tui/OverviewTab.java | 2 +- .../dsl/jbang/core/commands/tui/Placeholders.java | 59 ++++++++++++++ .../jbang/core/commands/tui/RouteDiagramZoom.java | 8 +- .../dsl/jbang/core/commands/tui/ShellPanel.java | 2 +- .../src/main/resources/tui/help/overview.md | 12 ++- .../dsl/jbang/core/commands/tui/AboutViewTest.java | 49 ++++++++++++ .../core/commands/tui/AbstractDiagramTabTest.java | 30 +++++++- .../commands/tui/IntegrationSummaryDocTest.java | 17 +++++ .../dsl/jbang/core/commands/tui/LoadAvgTest.java | 12 +++ .../core/commands/tui/McpFacadeControlTest.java | 10 +++ .../core/commands/tui/OverviewTabRenderTest.java | 20 +++++ .../jbang/core/commands/tui/PlaceholdersTest.java | 55 +++++++++++++ 23 files changed, 602 insertions(+), 36 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc index e72d184e60fc..d4076ea74616 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc @@ -24,10 +24,12 @@ happened to it, with no AI: image::jbang/camel-tui-about.png[About the chaos demo: its purpose, what comes in, its routes and what goes out, the last 5 minutes after an outage of the stock service, and a line per route of what happened] -* The header has the Camel version, runtime, files, routes and uptime, and the purpose: the comment at the top of the -main source file (a YAML `#` comment, a Javadoc or `//` comment in Java, an XML comment), else the descriptions of the -routes. -* *In*, *Routes* and *Out* say what starts it, its routes (a route called with `direct` sits under its caller with `↳`, +* The header has the Camel version, runtime, files, routes and uptime, and the purpose: the comment at the top of a +route file (a YAML `#` comment, a Javadoc or `//` comment in Java, an XML comment), else the first paragraph of the +README, else the descriptions of the routes, else the overview an AI wrote in `camel-summary.md`. *r* opens the README, +and *s* the summary of the project. +* *In*, *Routes* and *Out* say what starts it (a timer or a cron with the period or schedule of its properties, its +profile applied), its routes (a route called with `direct` sits under its caller with `↳`, one handed off with `seda` with `⇢`) and what it talks to, with the exchanges since it started and a sparkline of the last 5 minutes. *Up* and *Down* select a line, and *Enter* opens a route drilled down on the Diagram, or an endpoint with its details on the Endpoint tab. 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 index 8e4f3aeb1101..391987e36e6e 100644 --- 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 @@ -24,8 +24,10 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.stream.Stream; /** * About an integration (CAMEL-26700): what it does at a glance and without an AI, from the analysis of its sources @@ -78,6 +80,11 @@ public final class About { public record Model(String purpose, String purposeFrom, List<In> in, List<RouteLine> routes, List<Out> out) { } + // the names of a README, in the order they are taken + private static final List<String> README_NAMES = List.of("readme.md", "readme.adoc", "readme.txt", "readme"); + // shorter than this is no description (a link, a word) + private static final int MIN_PARAGRAPH = 20; + private About() { } @@ -143,8 +150,9 @@ public final class About { /** * 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. + * order of the analysis), else the first paragraph of its README, else the descriptions of the routes that + * something outside starts, else the overview an AI wrote in its camel-summary.md. Returns the text and where it + * comes from, or null. */ public static String[] purpose(Path dir, ProjectOverview.Overview o) { for (String file : o.files()) { @@ -157,6 +165,17 @@ public final class About { // an unreadable file has no comment } } + Path readme = readme(dir); + if (readme != null) { + try { + String text = firstParagraph(Files.readString(readme, StandardCharsets.UTF_8)); + if (text != null) { + return new String[] { text, "from " + readme.getFileName() }; + } + } catch (IOException e) { + // an unreadable README says nothing + } + } List<String> descriptions = new ArrayList<>(); Set<String> entryRoutes = new LinkedHashSet<>(); o.entryPoints().forEach(e -> entryRoutes.add(e.route())); @@ -175,9 +194,75 @@ public final class About { String.join(" ", descriptions.subList(0, Math.min(2, descriptions.size()))), "from the descriptions of the routes" }; } + IntegrationSummary.Summary summary = IntegrationSummary.read(dir); + String overview = summary != null ? firstParagraph(summary.overview()) : null; + if (overview != null) { + return new String[] { overview, "from " + IntegrationSummary.FILE_NAME + " (AI-assisted)" }; + } + return null; + } + + /** The README of the project: README.md, README.adoc, README.txt or README, whatever their case; or null. */ + public static Path readme(Path dir) { + if (dir == null || !Files.isDirectory(dir)) { + return null; + } + try (Stream<Path> files = Files.list(dir)) { + Map<String, Path> byName = new LinkedHashMap<>(); + files.filter(Files::isRegularFile) + .forEach(f -> byName.putIfAbsent(f.getFileName().toString().toLowerCase(Locale.ROOT), f)); + for (String name : README_NAMES) { + if (byName.containsKey(name)) { + return byName.get(name); + } + } + } catch (IOException e) { + // no README to read + } return null; } + /** + * The first paragraph of a Markdown or AsciiDoc text, as plain text: after its title, without badges, images, + * attributes and comments, and with the markup of code, emphasis and links taken out; null when it has none. + */ + public static String firstParagraph(String text) { + if (text == null) { + return null; + } + StringBuilder sb = new StringBuilder(); + boolean comment = false; + for (String line : text.lines().toList()) { + String l = line.strip(); + if (comment || l.startsWith("<!--")) { + comment = !l.contains("-->"); + continue; + } + if (l.isEmpty() || l.startsWith("#") || l.startsWith("=") || l.startsWith("```") || l.startsWith("----") + || l.startsWith("- ") || l.startsWith("* ") || l.startsWith("|") || l.startsWith(">")) { + if (!sb.isEmpty()) { + break; + } + continue; + } + if (l.startsWith("[!") || l.startsWith("[[") || l.matches("\\[[^]]*]") || l.startsWith("![") + || l.startsWith(":") || l.startsWith("//") || l.startsWith("image:") || l.startsWith("<")) { + // badges, images, attributes, anchors and comments of AsciiDoc, HTML + continue; + } + sb.append(l).append(' '); + } + String p = sb.toString() + .replaceAll("\\[([^\\]]*)]\\([^)]*\\)", "$1") // [text](url) + .replaceAll("(?:https?|link):[^\\s\\[]*\\[([^\\]]*)]", "$1") // link:url[text] and url[text] + .replaceAll("[`*]+|(?<!\\w)_+|_+(?!\\w)", "") + .replaceAll("\\s+", " ").strip(); + if (p.length() < MIN_PARAGRAPH) { + return null; + } + return p.length() > 300 ? p.substring(0, 297) + "…" : p; + } + /** * The comment a file starts with: the {@code #} lines of YAML, the Javadoc or {@code //} lines before the class of * Java, the {@code <!-- -->} of XML before its first element. A license header is no description: the comment after 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 index e9ce35a5ac45..73b26d7c27c3 100644 --- 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 @@ -89,6 +89,75 @@ class AboutTest { assertThat(About.leadingComment("a.yaml", null)).isNull(); } + @Test + void withoutACommentThePurposeIsTheFirstParagraphOfTheReadme() throws Exception { + // quick-start/routes: the bean file comes first, and neither file has a comment + Files.writeString(dir.resolve("beans.yaml"), "- beans:\n - name: greeter\n type: camel.example.Greeter\n"); + Files.writeString(dir.resolve("routes.camel.yaml"), """ + - route: + id: greeting + from: + uri: timer:greet + steps: + - log: hello + """); + assertThat(About.of(dir, ProjectOverview.analyze(dir, CATALOG)).purpose()).isNull(); + + // what the AI wrote comes last + Files.writeString(dir.resolve(IntegrationSummary.FILE_NAME), """ + # greeting + + ## Overview ✦ AI-assisted + + <!-- ai:begin --> + A timer greets every second. + + It has one route. + <!-- ai:end --> + """); + About.Model m = About.of(dir, ProjectOverview.analyze(dir, CATALOG)); + assertThat(m.purpose()).isEqualTo("A timer greets every second."); + assertThat(m.purposeFrom()).isEqualTo("from camel-summary.md (AI-assisted)"); + + // the author's words before: the README + Files.writeString(dir.resolve("README.md"), """ + # Routes + + [](https://example.org) + + The first step from YAML into your own code: a timer route in YAML calls a Java bean, `Greeter`, that + builds the message, and logs what the [bean](Greeter.java) returned. + + ## What you will see + """); + m = About.of(dir, ProjectOverview.analyze(dir, CATALOG)); + assertThat(m.purpose()).isEqualTo("The first step from YAML into your own code: a timer route in YAML calls a" + + " Java bean, Greeter, that builds the message, and logs what the bean" + + " returned."); + assertThat(m.purposeFrom()).isEqualTo("from README.md"); + assertThat(About.readme(dir)).isEqualTo(dir.resolve("README.md")); + } + + @Test + void theFirstParagraphOfAsciiDoc() { + assertThat(About.firstParagraph(""" + = Orders + :toc: + + // the shop + [NOTE] + Each order is reserved, paid and shipped, see link:docs/flow.adoc[the flow] and *the* _routes_. + + == Run + """)).isEqualTo("Each order is reserved, paid and shipped, see the flow and the routes."); + // a heading or a list only, or too short to say anything + assertThat(About.firstParagraph("# Title\n\n- one\n- two\n")).isNull(); + assertThat(About.firstParagraph("# Title\n\nSee below.\n")).isNull(); + assertThat(About.firstParagraph(null)).isNull(); + // snake_case is no emphasis + assertThat(About.firstParagraph("Reads the order_lines table of the shop database.")).contains("order_lines"); + } + @Test void thePurposeIsTheCommentAfterTheLicenseHeader() { String yaml = "# Licensed to the Apache Software Foundation (ASF)\n# under the License.\n\n" diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExamplesSayWhatTheyDoTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExamplesSayWhatTheyDoTest.java new file mode 100644 index 000000000000..828cbd0b12cc --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExamplesSayWhatTheyDoTest.java @@ -0,0 +1,60 @@ +/* + * 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.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import org.apache.camel.catalog.CamelCatalog; +import org.apache.camel.catalog.DefaultCamelCatalog; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Every bundled example says what it does in its own sources, as the About view of the TUI and camel get about show it + * first: the comment at the top of a route file, else the descriptions of its routes. + */ +class ExamplesSayWhatTheyDoTest { + + private static final Path EXAMPLES = Path.of("src/main/resources/examples"); + + @Test + void everyExampleHasAPurpose() throws IOException { + CamelCatalog catalog = new DefaultCamelCatalog(); + List<String> without = new ArrayList<>(); + int examples = 0; + try (Stream<Path> groups = Files.list(EXAMPLES)) { + for (Path group : groups.filter(Files::isDirectory).sorted().toList()) { + try (Stream<Path> dirs = Files.list(group)) { + for (Path dir : dirs.filter(d -> Files.isRegularFile(d.resolve("README.md"))).sorted().toList()) { + examples++; + if (About.purpose(dir, ProjectOverview.analyze(dir, catalog)) == null) { + without.add(EXAMPLES.relativize(dir).toString()); + } + } + } + } + } + assertThat(examples).isGreaterThan(25); + assertThat(without).as("examples without a comment at the top of a route file or a README").isEmpty(); + } +} 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 9d83e720e76d..855c13a5085d 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,6 +16,9 @@ */ 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; @@ -49,6 +52,7 @@ 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; import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; @@ -91,6 +95,8 @@ final class AboutView { private volatile boolean loading; private volatile String error; private Path dir; + // the README of the project, which r opens; null when it has none + private Path readme; private long loadedAt; // the zoom of What happened, as the windows of the Timeline tab (+ and -), and its last second when moved (← →); // -1 follows the last second recorded @@ -147,7 +153,9 @@ final class AboutView { try { ProjectOverview.Overview o = ProjectOverview.analyze(d, ArchitectureView.catalog()); About.Model m = About.of(d, o); + Path r = About.readme(d); onRenderThread.accept(() -> { + readme = r; overview = o; model = m; purpose = m.purpose(); @@ -177,6 +185,14 @@ final class AboutView { if (ke.isConfirm()) { return open(); } + if (ke.isChar('r') && readme != null) { + return openReadme(info); + } + if (ke.isChar('s') && dir != null) { + // the summary of the project, as the Diagram opens it: what an AI wrote, and what its sources say + IntegrationSummaryDoc.open(ctx, dir, null); + return true; + } long end = ctx.timeline.endSecond(info.pid); if (ke.isChar('+') || ke.isChar('=')) { if (zoom > 0 && end >= 0) { @@ -206,6 +222,22 @@ final class AboutView { return false; } + /** Opens the README of the project in the document viewer. */ + private boolean openReadme(IntegrationInfo info) { + if (ctx.openMarkdownCallback == null) { + return false; + } + try { + String text = Files.readString(readme, StandardCharsets.UTF_8); + String name = readme.getFileName().toString(); + String markdown = name.toLowerCase(Locale.ROOT).endsWith(".adoc") ? DocHelper.asciidocToMarkdown(text) : text; + ctx.openMarkdownCallback.accept((info.name != null ? info.name : info.pid) + " - " + name, markdown); + } catch (IOException e) { + error = "Cannot read " + readme + ": " + e.getMessage(); + } + return true; + } + /** The lines the cursor can be on, for an agent (tui_get_state): the route, or the endpoint and its route. */ SelectionContext selectionContext() { List<String> items = targetLabels; @@ -240,7 +272,7 @@ final class AboutView { JsonArray in = new JsonArray(); for (About.In e : m.in()) { JsonObject jo = new JsonObject(); - jo.put("label", e.label()); + jo.put("label", Placeholders.resolve(e.label(), info)); jo.put("kind", e.kind()); jo.put("routeId", e.route()); putTotalAndRate(jo, info, e.route(), totals); @@ -337,7 +369,8 @@ final class AboutView { } result.put("selected", sel); } - result.put("keys", "v all integrations, Up/Down select a line, Enter opens it, +/- zoom, Left/Right move, 4 Timeline"); + result.put("keys", "v all integrations, Up/Down select a line, Enter opens it, +/- zoom, Left/Right move, 4 Timeline," + + (readme != null ? " r the README," : "") + " s the summary"); return result; } @@ -512,7 +545,7 @@ final class AboutView { int tall = routes == 0 ? 1 : Math.max(1, Math.min(MAX_STRIP_ROWS, 1 + (area.height() - used - 2) / routes)); strips = routeStrips(info, width - 4, spaced, tall); } - select(columns, m); + select(columns, m, info); String name = " " + (info.name != null ? info.name : info.pid) + " "; if (roomy) { renderRoomy(frame, area, info, name, head, columns, status, strips); @@ -606,7 +639,7 @@ final class AboutView { head.add(Line.from(Span.styled(" " + pFrom, Theme.muted()))); } else if (o != null) { head.add(Line.from(Span.styled(" No description yet: a comment at the top of " + mainFile(o) - + " says what it does, or ask the AI (Shift+F8 on a route)", + + " or a README says what it does, or ask the AI (Shift+F8 on a route)", Theme.muted()))); } else { head.add(Line.from(Span.styled(error != null ? " " + error : " Reading the sources…", Theme.muted()))); @@ -615,12 +648,12 @@ final class AboutView { } /** The lines the cursor goes over, down In, then Routes, then Out; the line it is on in the selection colour. */ - private void select(Columns c, About.Model m) { + private void select(Columns c, About.Model m, IntegrationInfo info) { List<Target> all = new ArrayList<>(); List<String> labels = new ArrayList<>(); List<String> inLabels = new ArrayList<>(); if (m != null) { - m.in().stream().filter(e -> e.route() != null).forEach(e -> inLabels.add(e.label())); + m.in().stream().filter(e -> e.route() != null).forEach(e -> inLabels.add(Placeholders.resolve(e.label(), info))); } String[] names = { "in", "route", "out" }; List<List<Target>> columns = List.of(c.inTargets(), c.routeTargets(), c.outTargets()); @@ -896,8 +929,10 @@ final class AboutView { return sb.toString(); } + /** The file of the first route, where its comment goes: not a file that only declares beans. */ private static String mainFile(ProjectOverview.Overview o) { - return o.files().isEmpty() ? "the main file" : o.files().get(0); + return o.flows().stream().map(ProjectRoutes.Route::file).filter(f -> f != null).findFirst() + .orElse(o.files().isEmpty() ? "the main file" : o.files().get(0)); } // ---- In, Routes, Out ---- @@ -953,7 +988,9 @@ final class AboutView { inTargets.add(null); } Long total = e.route() != null ? totals.get(e.route()) : null; - in.add(Line.from(Span.raw(" " + icon(e.kind()) + " "), Span.styled(e.label(), Style.EMPTY.bold()), + // with the values of its properties: the period or the schedule it runs with, its profile applied + in.add(Line.from(Span.raw(" " + icon(e.kind()) + " "), + Span.styled(Placeholders.resolve(e.label(), info), 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 @@ -1190,12 +1227,23 @@ final class AboutView { return dot >= 0 ? type.substring(dot + 1) : type; } - /** Where to go next. */ - private static Line next() { - return Line.from(Span.styled(" Next ", Theme.muted()), Span.styled(" 4 ", Theme.hintKey()), - Span.raw(" what happened, per second "), Span.styled(" 5 ", Theme.hintKey()), - Span.raw(" diagram "), Span.styled(" 2 ", Theme.hintKey()), Span.raw(" source "), - Span.styled(" v ", Theme.hintKey()), Span.raw(" all integrations")); + /** Where to go next: the README and the summary of the project too. */ + private Line next() { + List<Span> spans = new ArrayList<>( + List.of(Span.styled(" Next ", Theme.muted()), Span.styled(" 4 ", Theme.hintKey()), + Span.raw(" what happened, per second "), Span.styled(" 5 ", Theme.hintKey()), + Span.raw(" diagram "), Span.styled(" 2 ", Theme.hintKey()), Span.raw(" source "))); + if (readme != null) { + spans.add(Span.styled(" r ", Theme.hintKey())); + spans.add(Span.raw(" readme ")); + } + if (dir != null) { + spans.add(Span.styled(" s ", Theme.hintKey())); + spans.add(Span.raw(" summary ")); + } + spans.add(Span.styled(" v ", Theme.hintKey())); + spans.add(Span.raw(" all integrations")); + return Line.from(spans); } /** diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java index f3f75f40c949..1b82731fe26a 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java @@ -3017,7 +3017,7 @@ class AiPanel { @Override public String selectedProcessName() { - return ctx != null ? ctx.selectedName() : null; + return ctx != null ? ctx.selectedFullName() : null; } @Override diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationSummaryDoc.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationSummaryDoc.java index b8235df4dd91..e6bf92f7fdbb 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationSummaryDoc.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationSummaryDoc.java @@ -48,11 +48,20 @@ final class IntegrationSummaryDoc { /** The summary of a project for the viewer, or null when there is no project or it has no routes. */ static Doc load(Path dir) { + return load(dir, null); + } + + /** + * The summary of a project for the viewer, named as the integration that runs it: an example runs from a directory + * of its own (camel-example-7061...), which is no name. + */ + static Doc load(Path dir, String integration) { if (dir == null) { return null; } ProjectOverview.Overview overview = ProjectOverview.analyze(dir, ProjectOverviewAssist.catalog()); - String name = dir.getFileName() != null ? dir.getFileName().toString() : dir.toString(); + String dirName = dir.getFileName() != null ? dir.getFileName().toString() : dir.toString(); + String name = integration != null ? integration : dirName; Path file = dir.resolve(IntegrationSummary.FILE_NAME); if (Files.isRegularFile(file)) { try { @@ -76,7 +85,8 @@ final class IntegrationSummaryDoc { "> Derived from the route sources. Not saved yet: /overview in the AI panel explains the" + " project and saves it as " + IntegrationSummary.FILE_NAME - + "."); + + ".") + .replace("Integration summary: " + dirName, "Integration summary: " + name); return new Doc("Integration summary · " + name + " (not saved)", forViewing(facts)); } @@ -169,7 +179,11 @@ final class IntegrationSummaryDoc { if (ctx == null || ctx.openMarkdownAtCallback == null) { return "The document viewer is not available"; } - Doc doc = load(dir); + // the name of the integration that runs from the directory + IntegrationInfo running = ctx.findSelectedIntegration(); + String name = running != null && running.directory != null && dir != null + && Path.of(running.directory).equals(dir) ? running.name : null; + Doc doc = load(dir, name); if (doc == null) { return dir == null ? "No source directory for the selected integration" diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LoadAvg.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LoadAvg.java index 02687cb5e4a4..bc26cc577db3 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LoadAvg.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LoadAvg.java @@ -26,14 +26,30 @@ class LoadAvg { private double load1 = Double.NaN; private double load5 = Double.NaN; private double load15 = Double.NaN; + private boolean updated; + + /** The averages start with the first value, as Camel's own load of inflight exchanges. */ + LoadAvg() { + } + + /** + * The averages start at a value, as a load average of the system starts at 0: the CPU of a process, whose first + * value is the start of its JVM, and is no average of the last 15 minutes. + */ + LoadAvg(double start) { + load1 = start; + load5 = start; + load15 = start; + } synchronized void update(double value) { + updated = true; load1 = Double.isNaN(load1) ? value : value + EXP_1 * (load1 - value); load5 = Double.isNaN(load5) ? value : value + EXP_5 * (load5 - value); load15 = Double.isNaN(load15) ? value : value + EXP_15 * (load15 - value); } synchronized String format(String fmt) { - return Double.isNaN(load1) ? "-" : String.format(Locale.US, fmt, load1, load5, load15); + return Double.isNaN(load1) || !updated ? "-" : String.format(Locale.US, fmt, load1, load5, load15); } } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java index 2486b66e6d78..f6e92c187d95 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java @@ -1765,7 +1765,7 @@ class McpFacade { if (ctx.selectedPid == null) { return "Error: no integration selected; give its name or pid"; } - String name = ctx.selectedName(); + String name = ctx.selectedFullName(); return switch (action) { case "reset-stats", "clear-stats" -> { if (ctx.isInfraSelected()) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MetricsCollector.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MetricsCollector.java index 2ffbfe4da81e..e6838df8cd42 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MetricsCollector.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MetricsCollector.java @@ -453,7 +453,7 @@ class MetricsCollector { long deltaWallNanos = (nowMs - prev[1]) * 1_000_000L; if (deltaWallNanos > 0) { double cpuPct = (double) deltaCpuNanos / deltaWallNanos * 100.0; - cpuLoadAvg.computeIfAbsent(pid, k -> new LoadAvg()).update(Math.max(0, cpuPct)); + cpuLoadAvg.computeIfAbsent(pid, k -> new LoadAvg(0)).update(Math.max(0, cpuPct)); } } prevCpuSample.put(pid, new long[] { cpuNanos, nowMs }); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java index c131107ef264..5ec98f37ebe5 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java @@ -179,6 +179,20 @@ class MonitorContext { return confirmActions && !tapeDriven.getAsBoolean(); } + /** + * The name of the selected integration or infra service as a name to act on: a camel command, an answer to a tool. + * {@link #selectedName()} is shortened for a title. + */ + String selectedFullName() { + IntegrationInfo info = findSelectedIntegration(); + if (info != null) { + return info.name; + } + InfraInfo infra = findSelectedInfra(); + return infra != null ? infra.alias : "?"; + } + + /** The name of the selected integration or infra service for a title, at most 20 characters. */ String selectedName() { IntegrationInfo info = findSelectedIntegration(); if (info != null) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java index aaad494296c4..055c3b20e3d8 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java @@ -723,7 +723,7 @@ class OverviewTab extends AbstractTab { throughputDisplay = TuiHelper.throughputPerMinute(throughputDisplay); } String timingCol; - if (hasPercentiles && info.p50Time >= 0) { + if (hasPercentiles && info.p50Time >= 0 && info.exchangesTotal > 0) { timingCol = formatDurationMs(info.p50Time) + "/" + formatDurationMs(info.p95Time) + "/" + formatDurationMs(info.p99Time); } else if (info.exchangesTotal > 0) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Placeholders.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Placeholders.java new file mode 100644 index 000000000000..5b4c68bacb26 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Placeholders.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * The property placeholders of a label read from the sources ({@code timer:welcome (period={{welcome.period}})}) with + * the values the running integration has for them, its profile applied: the properties of its status. A placeholder it + * has no value for stays as written, or takes its default ({@code {{key:default}}}). + */ +final class Placeholders { + + private static final Pattern PLACEHOLDER = Pattern.compile("\\{\\{([^{}:]+)(?::([^{}]*))?}}"); + + private Placeholders() { + } + + static String resolve(String text, IntegrationInfo info) { + if (text == null || !text.contains("{{") || info == null) { + return text; + } + Matcher m = PLACEHOLDER.matcher(text); + StringBuilder sb = new StringBuilder(); + while (m.find()) { + String value = value(info, m.group(1).strip()); + if (value == null) { + value = m.group(2) != null ? m.group(2) : m.group(); + } + m.appendReplacement(sb, Matcher.quoteReplacement(value)); + } + m.appendTail(sb); + return sb.toString(); + } + + private static String value(IntegrationInfo info, String key) { + for (ConfigurationTab.ConfigProperty p : info.configProperties) { + if (key.equals(p.key)) { + return p.value; + } + } + return null; + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteDiagramZoom.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteDiagramZoom.java index b26d0965f37a..acfca22415fa 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteDiagramZoom.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteDiagramZoom.java @@ -116,8 +116,12 @@ final class RouteDiagramZoom { return zoom; } - /** Keeps the density or fit, and the mini panels, for the next start; file work, so not on the UI thread. */ - void persist() { + /** + * Keeps the density or fit, and the mini panels, for the next start; file work, so not on the UI thread. One at a + * time: two keys pressed quickly save on two threads, and the one that runs last writes what is now, not what was + * when its key was pressed. + */ + synchronized void persist() { try { TuiUserConfig.write(PROP_ROUTE_ZOOM, setting()); TuiUserConfig.write(PROP_DIAGRAM_PANELS, panels ? null : "false"); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java index 4c7c20c9eb0c..d74475c66d2a 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java @@ -524,7 +524,7 @@ class ShellPanel { // Propagate TUI's selected integration so ask auto-targets it if (ctx != null && ctx.selectedPid != null) { - EnvironmentHelper.setSelectedProcess(ctx.selectedName()); + EnvironmentHelper.setSelectedProcess(ctx.selectedFullName()); } try (Shell shell = buildShell(terminal, registry)) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/overview.md b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/overview.md index 2ea670c691f2..e1542669660e 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/overview.md +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/overview.md @@ -16,9 +16,12 @@ What an integration you have not seen before does, and what has happened to it, with no AI: - **The header** — the Camel version, runtime, files, routes and uptime, - and its purpose: the comment at the top of its main file (a YAML `#` + and its purpose: the comment at the top of a route file (a YAML `#` comment, a Javadoc or `//` comment in Java, an XML comment), else the - descriptions of its routes. + first paragraph of its README, else the descriptions of its routes, + else the overview an AI wrote in its `camel-summary.md`. A timer or a + cron of In shows the period or schedule it runs with, from its + properties with its profile applied. - **In, Routes, Out** — what starts it (timers, HTTP, consumers), its routes (a route called with `direct:` sits under its caller with `↳`, one handed off with `seda:` with `⇢`) and what it talks to, from its @@ -37,7 +40,7 @@ happened to it, with no AI: were none in the last minute, with the earlier ones dimmed (Last 5m and What happened still show them). - **Next** — the keys that lead deeper: **4** the Timeline, **5** the - Diagram, **2** the source. + Diagram, **2** the source, **r** the README, **s** the summary. - **What happened** — a strip per route over the last 5 minutes (as wide as the screen allows, and up to two rows high on a tall one), coloured as on the Timeline: green completed, yellow recovered by redelivery, @@ -57,6 +60,9 @@ Keys of About: centres on the last trouble, so you see what the other routes did at that moment - **Left/Right** — Move a zoomed window back and forward +- **r** — Read the README of the project +- **s** — The summary of the project: what an AI wrote about it in + `camel-summary.md`, and what its sources say - **v** — All integrations `camel get about` shows the same from the command line. An AI reads it 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 245204ed0ee2..9689372b56b9 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 @@ -376,6 +376,55 @@ class AboutViewTest { }); } + @Test + void theQuickStartRoutesExampleSaysWhatItDoesAndWhenItRuns() throws Exception { + // quick-start/routes: the bean file comes first, and no file has a comment + Files.writeString(dir.resolve("beans.yaml"), "- beans:\n - name: greeter\n type: camel.example.Greeter\n"); + Files.writeString(dir.resolve("routes.camel.yaml"), """ + - route: + id: greeting + from: + uri: timer + parameters: + timerName: greet + period: "{{greeter.period}}" + steps: + - log: hello + """); + IntegrationInfo info = new IntegrationInfo(); + info.pid = "1234"; + info.name = "routes"; + info.directory = dir.toString(); + // the period the integration runs with, from its application.properties + ConfigurationTab.ConfigProperty period = new ConfigurationTab.ConfigProperty(); + period.key = "greeter.period"; + period.value = "2000"; + info.configProperties.add(period); + MonitorContext ctx = new MonitorContext(new AtomicReference<>(List.of(info)), new AtomicReference<>(List.of())); + + AboutView first = new AboutView(ctx); + await().atMost(20, TimeUnit.SECONDS).untilAsserted(() -> assertThat(render(first, info, 200, 34)) + // the file to put the comment in is the one with the route + .contains("No description yet: a comment at the top of routes.camel.yaml or a README says what it does") + .contains("timer:greet (period=2000)") + .contains("s summary").doesNotContain("r readme")); + + Files.writeString(dir.resolve("README.md"), """ + # Routes + + The first step from YAML into your own code: a timer route calls a Java bean. + """); + AboutView about = new AboutView(ctx); + List<String> opened = new ArrayList<>(); + ctx.openMarkdownCallback = (title, markdown) -> opened.add(title + ": " + markdown.lines().findFirst().orElse("")); + await().atMost(20, TimeUnit.SECONDS).untilAsserted(() -> assertThat(render(about, info, 200, 34)) + .contains("The first step from YAML into your own code: a timer route calls a Java bean.") + .contains("from README.md").contains("r readme")); + assertThat(about.handleKeyEvent(KeyEvent.ofChar('r'), info)).isTrue(); + assertThat(opened).containsExactly("routes - README.md: # Routes"); + assertThat(about.tableJson(info).getString("keys")).contains("r the README, s the summary"); + } + @Test void aboutAnIntegrationWithWhatHasHappened() throws Exception { Files.writeString(dir.resolve("shop.camel.yaml"), """ diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractDiagramTabTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractDiagramTabTest.java index c37f3ddce8e4..2843c0e4bced 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractDiagramTabTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractDiagramTabTest.java @@ -16,7 +16,9 @@ */ package org.apache.camel.dsl.jbang.core.commands.tui; +import java.nio.file.Path; import java.util.List; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import java.util.stream.Stream; @@ -26,6 +28,11 @@ import org.apache.camel.diagram.RouteDiagramLayoutEngine; import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutRoute; import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeInfo; import org.apache.camel.diagram.RouteDiagramLayoutEngine.RouteInfo; +import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.parallel.Isolated; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; @@ -34,11 +41,30 @@ import static org.assertj.core.api.Assertions.assertThat; /** * The route diagram keys the Diagram and Routes tabs share: they behave the same in both tabs. The Routes tab used to * keep the detail panel open when going back to the topology. + * <p> + * Isolated because it changes the home directory of the settings. */ +@Isolated class AbstractDiagramTabTest { - private final MonitorContext ctx = new MonitorContext( - new AtomicReference<>(List.of()), new AtomicReference<>(List.of())); + private MonitorContext ctx; + private String originalHome; + + @BeforeEach + void setUp(@TempDir Path home) { + // the keys save the zoom and the mini panels in the user's settings: not in the settings of who runs the test + originalHome = CommandLineHelper.getHomeDir().toString(); + CommandLineHelper.useHomeDir(home.toString()); + ctx = new MonitorContext(new AtomicReference<>(List.of()), new AtomicReference<>(List.of())); + } + + @AfterEach + void tearDown() throws InterruptedException { + // they save in the background: done before the home is back + ctx.backgroundExecutor.shutdown(); + ctx.backgroundExecutor.awaitTermination(10, TimeUnit.SECONDS); + CommandLineHelper.useHomeDir(originalHome); + } static Stream<Function<MonitorContext, AbstractDiagramTab>> tabs() { return Stream.of(DiagramTab::new, RoutesTab::new); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationSummaryDocTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationSummaryDocTest.java index 35fc9f63da2a..eae42a161289 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationSummaryDocTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationSummaryDocTest.java @@ -112,6 +112,23 @@ class IntegrationSummaryDocTest { assertTrue(IntegrationSummaryDoc.open(ctx, null, null).startsWith("No source directory")); } + @Test + void namedAsTheIntegrationThatRunsFromTheDirectory() throws Exception { + // an example runs from a directory of its own, camel-example-7061..., which is no name + write(ROUTES); + IntegrationInfo info = new IntegrationInfo(); + info.pid = "1234"; + info.name = "routes"; + info.directory = project.toString(); + MonitorContext ctx = new MonitorContext(new AtomicReference<>(List.of(info)), new AtomicReference<>(List.of())); + ctx.selectedPid = "1234"; + List<String> opened = new ArrayList<>(); + ctx.openMarkdownAtCallback = (title, markdown, heading) -> opened.add(title + " | " + markdown.lines().findFirst() + .orElse("")); + assertNull(IntegrationSummaryDoc.open(ctx, project, null)); + assertEquals(List.of("Integration summary · routes (not saved) | # Integration summary: routes"), opened); + } + @Test void viewerScrollsToThePendingHeading() { StringBuilder md = new StringBuilder("# Top\n\n"); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LoadAvgTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LoadAvgTest.java index 45781a7449b9..343b4e6a11c6 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LoadAvgTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LoadAvgTest.java @@ -40,6 +40,18 @@ class LoadAvgTest { assertEquals("10.00/10.00/10.00", result); } + @Test + void theCpuOfAJvmThatJustStartedIsNoAverageOfItsLast15Minutes() { + // the first second of a JVM: 500% CPU loading classes and compiling + LoadAvg cpu = new LoadAvg(0); + assertEquals("-", cpu.format("%.1f/%.1f/%.1f")); + cpu.update(500.0); + String[] parts = cpu.format("%.1f/%.1f/%.1f").split("/"); + // a second of 500% is 8% of a minute, and much less of 15 minutes + assertEquals(8.3, Double.parseDouble(parts[0]), 0.1); + assertTrue(Double.parseDouble(parts[2]) < 1.0, parts[2]); + } + @Test void multipleUpdatesConvergeViaEwma() { LoadAvg avg = new LoadAvg(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeControlTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeControlTest.java index dbd892585af9..30e7ce73df95 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeControlTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeControlTest.java @@ -52,6 +52,16 @@ class McpFacadeControlTest { assertThat(calls).containsExactly("stopProcess [false]"); } + @Test + void aLongNameIsAnsweredInFull() { + // run/properties-and-profiles: the title shortens it, the answer and the shell must not + integration("properties-and-profiles", "100"); + + assertThat(facade.controlIntegration("stop", "properties-and-profiles")).isEqualTo("Stopping properties-and-profiles"); + assertThat(ctx.selectedFullName()).isEqualTo("properties-and-profiles"); + assertThat(ctx.selectedName()).isEqualTo("properties-and-pr..."); + } + @Test void aPidWorksLikeAName() { integration("orders", "100"); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTabRenderTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTabRenderTest.java index 11e31f58c5e7..40775c212117 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTabRenderTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTabRenderTest.java @@ -85,6 +85,26 @@ class OverviewTabRenderTest { assertTrue(rendered.contains("TOTAL"), "Should show TOTAL header"); } + @Test + void noTimesBeforeTheFirstExchange() { + // quick-start/rest-api before its first request: the percentiles are there, all 0 + info.state = 5; + info.p50Time = 0; + info.p95Time = 0; + info.p99Time = 0; + + OverviewTab tab = new OverviewTab(ctx, new MetricsCollector(), new HashSet<>(), () -> { + }).showIntegrations(); + String row = TuiTestHelper.renderToString(tab, 160, 30).lines().filter(l -> l.contains("test-app")).findFirst() + .orElseThrow(); + assertFalse(row.contains("0ms/0ms/0ms"), row); + + info.exchangesTotal = 1; + row = TuiTestHelper.renderToString(tab, 160, 30).lines().filter(l -> l.contains("test-app")).findFirst() + .orElseThrow(); + assertTrue(row.contains("0ms/0ms/0ms"), row); + } + @Test void theNumbersStandNextToTheNamesOnAWideScreen() { info.state = 5; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/PlaceholdersTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/PlaceholdersTest.java new file mode 100644 index 000000000000..fa7aea3c9cf0 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/PlaceholdersTest.java @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * run/properties-and-profiles: the timer of the route runs every {{welcome.period}}, which application.properties sets; + * About shows the value the integration runs with. + */ +class PlaceholdersTest { + + @Test + void theValuesTheIntegrationRunsWith() { + IntegrationInfo info = new IntegrationInfo(); + add(info, "welcome.period", "3000"); + add(info, "report.schedule", "0/10 * * * * ?"); + + assertThat(Placeholders.resolve("timer:welcome (period={{welcome.period}})", info)) + .isEqualTo("timer:welcome (period=3000)"); + assertThat(Placeholders.resolve("cron:report (schedule={{ report.schedule }})", info)) + .isEqualTo("cron:report (schedule=0/10 * * * * ?)"); + // one it has no value for: its default, else as written + assertThat(Placeholders.resolve("timer:t (period={{tick:1000}}, delay={{start}})", info)) + .isEqualTo("timer:t (period=1000, delay={{start}})"); + // a value with a $ or a \\ is taken as it is + add(info, "odd", "$1\\x"); + assertThat(Placeholders.resolve("{{odd}}", info)).isEqualTo("$1\\x"); + assertThat(Placeholders.resolve("timer:plain", info)).isEqualTo("timer:plain"); + assertThat(Placeholders.resolve(null, info)).isNull(); + } + + private static void add(IntegrationInfo info, String key, String value) { + ConfigurationTab.ConfigProperty p = new ConfigurationTab.ConfigProperty(); + p.key = key; + p.value = value; + info.configProperties.add(p); + } +}
