This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new f9e1a6a38974 chore: camel-jbang - About says what an integration does
from its README too, counts an exchange once, and new screenshots of About and
the Timeline (#29535)
f9e1a6a38974 is described below
commit f9e1a6a389749ef4fad52e8ce492eb46a602103c
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Nov 10 08:58:44 2026 +0100
chore: camel-jbang - About says what an integration does from its README
too, counts an exchange once, and new screenshots of About and the Timeline
(#29535)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../modules/ROOT/images/jbang/camel-tui-about.png | Bin 416363 -> 375997 bytes
.../jbang/camel-tui-timeline-circuit-breaker.png | Bin 250813 -> 278036 bytes
.../images/jbang/camel-tui-timeline-exchanges.png | Bin 303395 -> 251099 bytes
.../ROOT/images/jbang/camel-tui-timeline.png | Bin 334719 -> 274988 bytes
.../ROOT/pages/camel-jbang-tui-observe.adoc | 22 ++--
.../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 | 112 +++++++++++++++++----
.../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 | 17 +++-
.../dsl/jbang/core/commands/tui/AboutViewTest.java | 99 ++++++++++++++++++
.../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 ++++++++++
27 files changed, 692 insertions(+), 49 deletions(-)
diff --git a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-about.png
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-about.png
index a82bde4ac371..58ba09ec3e10 100644
Binary files a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-about.png
and b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-about.png differ
diff --git
a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-timeline-circuit-breaker.png
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-timeline-circuit-breaker.png
index 329d4ecf069f..eae87c96fed2 100644
Binary files
a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-timeline-circuit-breaker.png
and
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-timeline-circuit-breaker.png
differ
diff --git
a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-timeline-exchanges.png
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-timeline-exchanges.png
index a394038bfd2e..4a14e99e5d3c 100644
Binary files
a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-timeline-exchanges.png
and
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-timeline-exchanges.png
differ
diff --git a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-timeline.png
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-timeline.png
index 18cd52c968c7..dd54fc1c97e4 100644
Binary files
a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-timeline.png and
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-timeline.png differ
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 98e4bc0533df..06564f0275ff 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
@@ -22,12 +22,14 @@ The Overview tab (key *1*) has two views, switched with
*v*: *About* an integrat
*Integrations*, the table of all of them. About tells what an integration you
have not seen before does, and what has
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 `β³`,
+image::jbang/camel-tui-about.png[About the circuit breaker example: its
purpose, the timer it starts from, the supplier route it calls, the exchanges
and errors of the last minutes, and a line per route of what happened, zoomed
in on the outage of the supplier]
+
+* 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` or a `wireTap` 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.
@@ -47,10 +49,10 @@ as `No status`.
The Timeline tab (key *4*) shows the last minute of the selected integration
on one time axis, one column per second:
what went wrong on top, and what it did to the routes under it. Read from left
to right, it tells the story of an
-outage: the stock service answered 503 for fifteen seconds, the orders went to
the dead letter channel, shipping had
-nothing to ship, and everything recovered by itself.
+outage: in the circuit breaker example the supplier refuses three calls, the
circuit breaker of stock-check opens and
+answers from its fallback for ten seconds, and everything recovers by itself.
-image::jbang/camel-tui-timeline.png[The Timeline tab in an outage of the stock
service: the errors per second on top, the routes under it, and the orders
route in detail]
+image::jbang/camel-tui-timeline.png[The Timeline tab in an outage of the
supplier: the errors per second on top, the routes under it, and the supplier
route in detail]
* *Errors/s* stacks the errors per second by the kind of exception, with the
HTTP status when there is one. With
camel-chaos, the faults of each chaos rule are a layer of their own, marked π²,
so an injected fault and a real one stay
@@ -75,7 +77,7 @@ lists the exchanges of the second at the cursor: the failed,
handled and recover
outcome, time and slowest call. *Enter* again shows the selected exchange with
the remote calls it made and its exception,
or for a recovered one the failure it was redelivered for.
-image::jbang/camel-tui-timeline-exchanges.png[The exchanges of a second in the
outage: the orders handled by the dead letter channel, one of them faulted by
chaos rule r1]
+image::jbang/camel-tui-timeline-exchanges.png[The exchanges of a second of the
error handling example: one order recovered by redelivery, and one handled by
the error handler and parked as a file]
*Shift+F8* asks the AI to explain what happened, as a story of cause and
effect.
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 630723f681ac..80058d5e2230 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);
@@ -607,7 +640,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())));
@@ -616,12 +649,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());
@@ -897,8 +930,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 ----
@@ -954,7 +989,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
@@ -1198,12 +1235,33 @@ 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"));
+ /** How each route that another one sends to is reached: call (direct) or
a hand-off (seda); none for the others. */
+ private Map<String, String> howCalled() {
+ Map<String, String> how = new LinkedHashMap<>();
+ About.Model m = model;
+ if (m != null) {
+ m.routes().stream().filter(r -> r.how() != null).forEach(r ->
how.put(r.routeId(), r.how()));
+ }
+ return how;
+ }
+
+ /** 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);
}
/**
@@ -1226,18 +1284,32 @@ final class AboutView {
long recovered = 0;
long handled = 0;
long failed = 0;
+ // what went wrong on a hand-off, to an exchange already counted
+ long handedOff = 0;
+ Map<String, String> how = howCalled();
for (Timeline.RouteSeries r : series) {
- completed += r.total(r.completed());
- recovered += r.total(r.recovered());
+ String called = how.get(r.route());
+ if ("call".equals(called)) {
+ // a route called with direct is part of the exchange of its
caller, which gets its failure too
+ continue;
+ }
+ if (called == null) {
+ completed += r.total(r.completed());
+ recovered += r.total(r.recovered());
+ for (int i = 0; i < HAPPENED_SECONDS; i++) {
+ perSecond[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]);
+ }
+ }
+ // one handed off (seda) carries the same message on: only what
went wrong there, which its sender does not see
handled += r.total(r.handled());
failed += r.total(r.failed());
- for (int i = 0; i < HAPPENED_SECONDS; i++) {
- perSecond[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]);
+ if (called != null) {
+ handedOff += r.total(r.handled()) + r.total(r.failed());
}
}
spans.add(Span.styled(spark(perSecond, 30), Theme.info()));
- long done = completed + recovered + handled + failed;
+ long done = completed + recovered + handled + failed - handedOff;
spans.add(Span.styled(" " + done, Theme.success()));
spans.add(Span.raw(done == 1 ? " exchange" : " exchanges"));
if (recovered > 0) {
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 76c4295defea..de0649a95274 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
@@ -1766,7 +1766,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 01f9c21a214d..a40f6f5079f8 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
@@ -722,7 +722,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 5ed841d8d90c..6c2570b9add7 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:` or a `wireTap` with `β’`) and what it talks to,
from its
@@ -30,7 +33,10 @@ happened to it, with no AI:
service are one line (`http://localhost:8080/stock/β¦`).
- **Last 5m** β the exchanges and how they ended, the cause that
happened most and when (a chaos rule marked π²), and the exchanges
- since it started.
+ since it started. An exchange counts once, not once for every route it
+ passes: a route called with `direct:` is part of its caller's
+ exchange, and of one handed off with `seda:` only what went wrong
+ there counts.
- **Watch** β the errors that happened, the kinds that happened most and
when the last one did, routes not started, exchanges in flight. Errors
that stopped more than a minute ago are history: the line says there
@@ -41,7 +47,7 @@ happened to it, with no AI:
idle when a debugger holds it. Its numbers are of then, and a stop
may not reach it; `F10` kills it.
- **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,
@@ -61,6 +67,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 0bdb12d6c214..f748a70566ba 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
@@ -395,6 +395,105 @@ 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 lastFiveMinutesCountsAnExchangeOnceNotOncePerRouteItPasses() throws
Exception {
+ // fail-well/circuit-breaker: stock-check calls supplier with direct,
once a second
+ Files.writeString(dir.resolve("circuit-breaker.camel.yaml"), """
+ - route:
+ id: stock-check
+ from:
+ uri: timer:stock
+ steps:
+ - to: direct:supplier
+ - to: seda:report
+ - route:
+ id: supplier
+ from:
+ uri: direct:supplier
+ steps:
+ - log: called
+ - route:
+ id: report
+ from:
+ uri: seda:report
+ steps:
+ - log: reported
+ """);
+ IntegrationInfo info = new IntegrationInfo();
+ info.pid = "1234";
+ info.name = "circuit-breaker";
+ info.directory = dir.toString();
+ for (String id : List.of("stock-check", "supplier", "report")) {
+ RouteInfo r = new RouteInfo();
+ r.routeId = id;
+ r.state = "Started";
+ info.routes.add(r);
+ }
+ TimelineJson t = new TimelineJson(T0 / 1000 + 59, 60);
+ for (int i = 50; i < 60; i++) {
+ t.route("stock-check", "completed", i, 1).route("supplier",
"completed", i, 1);
+ // the report, handed off, fails once: its sender does not see that
+ t.route("report", i == 55 ? "failed" : "completed", i, 1);
+ }
+ MonitorContext ctx = new MonitorContext(new
AtomicReference<>(List.of(info)), new AtomicReference<>(List.of()));
+ ctx.timeline.merge("1234", t.json(), true);
+ AboutView about = new AboutView(ctx);
+
+ await().atMost(20, TimeUnit.SECONDS).untilAsserted(() ->
assertThat(render(about, info, 200, 34))
+ .contains("β³ supplier")
+ // 10 checks, not 30 for the three routes they went through
+ .contains("10 exchanges Β· 1 failed").doesNotContain("30
exchanges").doesNotContain("20 exchanges"));
+ }
+
@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);
+ }
+}