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 48f3d1a1d5c0 chore: camel-jbang - error bodies as sent, the elapsed
time of the whole exchange, a rate for apps that report none, a failed Spring
Boot start (#27219)
48f3d1a1d5c0 is described below
commit 48f3d1a1d5c0ddf170aca43a83ad988a1809ce82
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 1 19:45:05 2026 +0200
chore: camel-jbang - error bodies as sent, the elapsed time of the whole
exchange, a rate for apps that report none, a failed Spring Boot start (#27219)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 25 +++++-
.../jbang/core/commands/ai/AuthoringToolsTest.java | 12 +++
.../dsl/jbang/core/commands/tui/BrowseTab.java | 3 +-
.../core/commands/tui/DataRefreshService.java | 3 +
.../dsl/jbang/core/commands/tui/HistoryTab.java | 11 +--
.../dsl/jbang/core/commands/tui/LaunchManager.java | 95 +++++++++++++++++++---
.../dsl/jbang/core/commands/tui/McpFacade.java | 37 +++++++--
.../dsl/jbang/core/commands/tui/RateEstimator.java | 86 ++++++++++++++++++++
.../dsl/jbang/core/commands/tui/StatusParser.java | 21 ++++-
.../core/commands/tui/HistoryTabRenderTest.java | 18 ++++
.../tui/LaunchManagerStartFailureTest.java | 79 ++++++++++++++++++
.../commands/tui/McpFacadeFindIntegrationTest.java | 66 +++++++++++++++
.../jbang/core/commands/tui/RateEstimatorTest.java | 93 +++++++++++++++++++++
.../jbang/core/commands/tui/StatusParserTest.java | 11 +++
14 files changed, 529 insertions(+), 31 deletions(-)
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
index bcbccac9a3b0..2d1ff2dd2d84 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
@@ -40,6 +40,7 @@ import java.util.regex.Pattern;
import org.apache.camel.dsl.jbang.core.common.RuntimeHelper;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
import static org.apache.camel.dsl.jbang.core.commands.ai.ToolDescriptor.tool;
@@ -304,7 +305,7 @@ public final class AuthoringTools {
.executor((ctx, args) -> {
selectProcess(ctx, args);
JsonObject errors = ctx.readErrorFile();
- return errors != null ? errors.toJson() : "No errors
captured.";
+ return errors != null ? unescapeBodies(errors).toJson() :
"No errors captured.";
}));
registry.accept(tool("camel_eval_expression",
@@ -1348,4 +1349,26 @@ public final class AuthoringTools {
return defaultValue;
}
}
+
+ /**
+ * Camel JSON-escapes the body of a message dump (MessageHelper), so a
JSON body would reach the AI escaped twice (a
+ * body like {"orderId":1} showed as {\\"orderId\\":1}). The body values
of the errors are unescaped once, as the
+ * TUI shows them.
+ */
+ static JsonObject unescapeBodies(JsonObject errors) {
+ Collection<Object> list = errors.getCollection("errors");
+ if (list != null) {
+ for (Object e : list) {
+ if (e instanceof JsonObject error && error.get("message")
instanceof JsonObject message
+ && message.get("body") instanceof JsonObject body &&
body.get("value") instanceof String value) {
+ try {
+ body.put("value", Jsoner.unescape(value));
+ } catch (Exception ex) {
+ // keep the value as it came
+ }
+ }
+ }
+ }
+ return errors;
+ }
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
index dad86d5b188e..4ab4d5def8a8 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
@@ -355,4 +355,16 @@ class AuthoringToolsTest {
"no-such-app-xyz")));
assertTrue(e.getMessage().contains("no-such-app-xyz"), e.getMessage());
}
+
+ @Test
+ void theErrorsGiveTheBodyAsTheAppSentIt() throws Exception {
+ // MessageHelper escapes the body value, as a dev console sends it
+ JsonObject errors = (JsonObject) Jsoner.deserialize("""
+
{"errors":[{"routeId":"checkout","message":{"body":{"type":"String",
+
"value":"{\\\\\\\"orderId\\\\\\\":\\\\\\\"ORD-1003\\\\\\\"}"}}}]}""");
+ String json = AuthoringTools.unescapeBodies(errors).toJson();
+ JsonObject body = (JsonObject) ((JsonObject) ((JsonObject)
((JsonArray) ((JsonObject) Jsoner.deserialize(json))
+ .get("errors")).get(0)).get("message")).get("body");
+ assertEquals("{\"orderId\":\"ORD-1003\"}", body.getString("value"));
+ }
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/BrowseTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/BrowseTab.java
index d084bd52277a..36ae33785b10 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/BrowseTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/BrowseTab.java
@@ -703,8 +703,7 @@ class BrowseTab extends AbstractTab {
// body is a JsonObject with {type, value}
JsonObject bodyObj = message.getMap("body");
if (bodyObj != null) {
- Object val = bodyObj.get("value");
- md.body = val != null ? val.toString() : null;
+ md.body = StatusParser.bodyText(bodyObj.get("value"));
}
result.add(md);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
index 6a1e04cd045a..733be4362225 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
@@ -77,6 +77,7 @@ class DataRefreshService {
// Sparkline/chart history for all metric families
private final MetricsCollector metrics = new MetricsCollector();
+ private final RateEstimator rates = new RateEstimator();
// Cached PID list -- full process scan throttled to every 2 seconds (1
second in burst mode)
private volatile List<Long> cachedPids = Collections.emptyList();
@@ -274,6 +275,7 @@ class DataRefreshService {
info.activity = prev.activity;
info.errors = prev.errors;
}
+ rates.fill(info, now);
infos.add(info);
metrics.updateThroughputHistory(info);
metrics.updateEndpointHistory(info);
@@ -314,6 +316,7 @@ class DataRefreshService {
handleVanishing(infos, now);
mergePhantoms(infos);
+ rates.retain(infos.stream().map(i ->
i.pid).collect(Collectors.toSet()));
data.set(infos);
return fullScan;
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
index 4b6fed1c60ac..5d41814776ac 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
@@ -2111,16 +2111,17 @@ class HistoryTab extends AbstractTab {
.build();
}
- private static Title buildHistoryTitle(List<HistoryEntry> entries) {
+ static Title buildHistoryTitle(List<HistoryEntry> entries) {
if (entries.isEmpty()) {
return Title.from(" History of last completed ");
}
HistoryEntry first = entries.get(0);
+ // the exchange completes at its last "last" step: a route called with
direct or seda returns earlier, with
+ // its own (shorter) elapsed time
HistoryEntry last = null;
for (HistoryEntry e : entries) {
if (e.last) {
last = e;
- break;
}
}
if (last == null) {
@@ -2247,11 +2248,7 @@ class HistoryTab extends AbstractTab {
} else {
lines.add(Line.from(Span.styled(" Body:", headerStyle)));
}
- try {
- body = Jsoner.unescape(body);
- } catch (Exception e) {
- // ignore
- }
+ // the body is unescaped when it is parsed (StatusParser.bodyText)
String[] bodyParts = body.split("\n");
for (String bl : bodyParts) {
lines.add(Line.from(Span.raw(" " + stripControlChars(bl))));
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManager.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManager.java
index c169ad862787..8e1fd7a253f9 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManager.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManager.java
@@ -19,6 +19,8 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -30,6 +32,7 @@ import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import java.util.function.Supplier;
+import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.apache.camel.dsl.jbang.core.common.ExampleHelper;
@@ -337,19 +340,35 @@ class LaunchManager {
Iterator<PendingLaunch> it = pendingLaunches.iterator();
while (it.hasNext()) {
PendingLaunch pl = it.next();
- if (!pl.process().isAlive()) {
- int exitCode = pl.process().exitValue();
- if (exitCode == 0) {
- notify("Started: " + pl.name(), false);
- } else {
- if (failureLogCallback != null) {
- failureLogCallback.accept(pl.name(), pl.outputFile());
+ if (!pl.process.isAlive()) {
+ int exitCode = pl.process.exitValue();
+ if (exitCode == 0 || pl.started) {
+ if (!pl.announced) {
+ notify("Started: " + pl.name, false);
}
+ } else if (failureLogCallback != null) {
+ failureLogCallback.accept(pl.name, pl.outputFile);
}
it.remove();
- } else if (now - pl.startTime() > 8000) {
- notify("Started: " + pl.name(), false);
+ } else if (pl.started) {
+ // up and running: a stop or a failure from now on is not a
failed start
it.remove();
+ } else if (pl.startFailed()) {
+ // the app gave up starting (port in use, build failure) but
its JVM lives on: stop it, and show why
+ pl.process.descendants().forEach(ProcessHandle::destroy);
+ pl.process.destroy();
+ if (failureLogCallback != null) {
+ failureLogCallback.accept(pl.name, pl.outputFile);
+ }
+ it.remove();
+ } else {
+ if (!pl.announced && now - pl.startTime > 8000) {
+ notify("Started: " + pl.name, false);
+ pl.announced = true;
+ }
+ if (now - pl.startTime > WATCH_MS) {
+ it.remove();
+ }
}
}
}
@@ -360,7 +379,63 @@ class LaunchManager {
}
}
- private record PendingLaunch(String name, Process process, Path
outputFile, long startTime) {
+ /**
+ * How long a launch is watched for a failed start: a Maven project builds
before it starts, and Spring Boot only
+ * then finds that its port is in use.
+ */
+ static final long WATCH_MS = 5 * 60_000;
+
+ /** What a runtime prints when it gives up starting, while its JVM may
stay up. */
+ static final List<String> START_FAILURES = List.of(
+ "APPLICATION FAILED TO START", "[ERROR] BUILD FAILURE", "Failed to
start application");
+
+ /** What Camel prints when it has started, whatever the runtime: the start
did not fail. */
+ static final Pattern STARTED = Pattern.compile("Apache Camel \\S+ \\(.*\\)
started in");
+
+ /** A started process, watched until it is up for a while, ends, or fails
to start. */
+ static final class PendingLaunch {
+ final String name;
+ final Process process;
+ final Path outputFile;
+ final long startTime;
+ boolean announced;
+ // Camel said it started: the launch is no longer watched for a failed
start
+ boolean started;
+ private long offset;
+
+ PendingLaunch(String name, Process process, Path outputFile, long
startTime) {
+ this.name = name;
+ this.process = process;
+ this.outputFile = outputFile;
+ this.startTime = startTime;
+ }
+
+ /** Whether the output printed since the last look says the start
failed. */
+ boolean startFailed() {
+ if (outputFile == null) {
+ return false;
+ }
+ try (var channel = Files.newByteChannel(outputFile)) {
+ long size = channel.size();
+ if (size <= offset) {
+ return false;
+ }
+ // a marker can be cut by the previous look: read a little of
what was seen before
+ long from = Math.max(0, offset - 64);
+ ByteBuffer buf = ByteBuffer.allocate((int) Math.min(size -
from, 1024 * 1024));
+ channel.position(from);
+ channel.read(buf);
+ offset = from + buf.position();
+ String text = new String(buf.array(), 0, buf.position(),
StandardCharsets.UTF_8);
+ if (START_FAILURES.stream().anyMatch(text::contains)) {
+ return true;
+ }
+ started |= STARTED.matcher(text).find();
+ return false;
+ } catch (IOException e) {
+ return false;
+ }
+ }
}
private record DeferredLaunch(
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 071a4624249b..3bd30c03458b 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
@@ -393,21 +393,42 @@ class McpFacade {
}
String selectIntegration(String nameOrPid) {
- List<IntegrationInfo> infos = data.get();
+ IntegrationInfo info = findIntegration(data.get(), nameOrPid);
+ if (info == null) {
+ return null;
+ }
+ ctx.selectedPid = info.pid;
+ bridge.resetIntegrationTabState();
+ return info.name != null ? info.name : info.pid;
+ }
+
+ /**
+ * The integration by its pid or name, else by the folder of its project:
an opened project runs under the name its
+ * app gives itself (camel.main.name, spring.application.name), not the
name of the folder it was opened as.
+ */
+ static IntegrationInfo findIntegration(List<IntegrationInfo> infos, String
nameOrPid) {
for (IntegrationInfo info : infos) {
- if (info.vanishing) {
- continue;
+ if (!info.vanishing && (nameOrPid.equals(info.pid)
+ || (info.name != null &&
info.name.equalsIgnoreCase(nameOrPid)))) {
+ return info;
}
- if (nameOrPid.equals(info.pid)
- || (info.name != null &&
info.name.equalsIgnoreCase(nameOrPid))) {
- ctx.selectedPid = info.pid;
- bridge.resetIntegrationTabState();
- return info.name != null ? info.name : info.pid;
+ }
+ for (IntegrationInfo info : infos) {
+ if (!info.vanishing && (folderNamed(info.directory, nameOrPid) ||
folderNamed(info.sourceDir, nameOrPid))) {
+ return info;
}
}
return null;
}
+ private static boolean folderNamed(String dir, String name) {
+ if (dir == null || dir.isBlank()) {
+ return false;
+ }
+ Path folder = Path.of(dir).getFileName();
+ return folder != null && folder.toString().equalsIgnoreCase(name);
+ }
+
List<String> getTabNames() {
List<String> names = new ArrayList<>();
names.addAll(List.of(TAB_NAMES));
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RateEstimator.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RateEstimator.java
new file mode 100644
index 000000000000..868cc2c275cb
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RateEstimator.java
@@ -0,0 +1,86 @@
+/*
+ * 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.ArrayDeque;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * The message rate of an integration that does not report one. The app
measures its throughput with the load statistics
+ * timer, which a Quarkus or Spring Boot app in the prod profile (or an older
Camel) does not run: it then reports no
+ * rate, or 0.00, while its total keeps growing. The rate is then measured
here, from how much the total grew over the
+ * last seconds of polls.
+ */
+class RateEstimator {
+
+ static final long WINDOW_MS = 10_000;
+
+ private record Sample(long time, long total) {
+ }
+
+ private final Map<String, Deque<Sample>> samples = new HashMap<>();
+
+ /** Fills the rate of the integration and its routes when the app reported
none. */
+ void fill(IntegrationInfo info, long now) {
+ info.throughput = estimate(info.pid, info.throughput,
info.exchangesTotal, now);
+ for (RouteInfo route : info.routes) {
+ route.throughput = estimate(info.pid + "/" + route.routeId,
route.throughput, route.total, now);
+ }
+ }
+
+ /** Forgets the integrations that are gone. */
+ void retain(Set<String> pids) {
+ samples.keySet().removeIf(key -> !pids.contains(key.contains("/") ?
key.substring(0, key.indexOf('/')) : key));
+ }
+
+ String estimate(String key, String reported, long total, long now) {
+ Deque<Sample> window = samples.computeIfAbsent(key, k -> new
ArrayDeque<>());
+ if (!window.isEmpty() && total < window.peekLast().total()) {
+ // the statistics were reset (or the app restarted): measure again
from here
+ window.clear();
+ }
+ window.addLast(new Sample(now, total));
+ while (window.size() > 2 && now - window.peekFirst().time() >
WINDOW_MS) {
+ window.removeFirst();
+ }
+ if (hasRate(reported)) {
+ return reported;
+ }
+ Sample first = window.peekFirst();
+ long elapsed = now - first.time();
+ if (elapsed <= 0 || total <= first.total()) {
+ return reported;
+ }
+ double perSecond = (total - first.total()) * 1000.0 / elapsed;
+ return String.format(Locale.US, "%.2f", perSecond);
+ }
+
+ private static boolean hasRate(String reported) {
+ if (reported == null || reported.isBlank()) {
+ return false;
+ }
+ try {
+ return Double.parseDouble(reported) > 0;
+ } catch (NumberFormatException e) {
+ return false;
+ }
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
index e9cebdb3cdeb..f7330766e055 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
@@ -1100,6 +1100,22 @@ final class StatusParser {
return entry;
}
+ /**
+ * The body as the app sent it: Camel JSON-escapes the body value of a
message dump (MessageHelper), so a JSON body
+ * arrives as {\"orderId\":...}. Unescaped once here, so every view and
tool shows the real body.
+ */
+ static String bodyText(Object value) {
+ if (value == null) {
+ return null;
+ }
+ String text = value.toString();
+ try {
+ return Jsoner.unescape(text);
+ } catch (Exception e) {
+ return text;
+ }
+ }
+
@SuppressWarnings("unchecked")
static MessageData parseMessage(JsonObject message) {
Map<String, Object> headers = null;
@@ -1132,8 +1148,7 @@ final class StatusParser {
Object bodyObj = message.get("body");
if (bodyObj instanceof JsonObject bodyJson) {
- Object val = bodyJson.get("value");
- body = val != null ? val.toString() : null;
+ body = bodyText(bodyJson.get("value"));
bodyType = TuiHelper.shortTypeName(bodyJson.getString("type"));
if (bodyJson.get("size") instanceof Number n) {
bodySize = n.longValue();
@@ -1451,7 +1466,7 @@ final class StatusParser {
if (msg != null) {
Object bodyObj = msg.get("body");
if (bodyObj instanceof JsonObject bodyJson) {
- ei.body = bodyJson.getString("value");
+ ei.body = bodyText(bodyJson.get("value"));
ei.bodyType =
TuiHelper.shortTypeName(bodyJson.getString("type"));
if (bodyJson.get("size") instanceof Number n) {
ei.bodySize = n.longValue();
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTabRenderTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTabRenderTest.java
index cba420b3f7c6..ee5ebb15c425 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTabRenderTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTabRenderTest.java
@@ -189,4 +189,22 @@ class HistoryTabRenderTest {
return te;
}
+ @Test
+ void theTitleGivesTheElapsedTimeOfTheWholeExchange() {
+ // checkout calls payment-provider over direct: the called route
returns first, after 0ms
+ HistoryEntry from = historyEntry("checkout", 0, false);
+ HistoryEntry called = historyEntry("payment-provider", 0, true);
+ HistoryEntry done = historyEntry("checkout", 17, true);
+ String title = HistoryTab.buildHistoryTitle(List.of(from, called,
done)).content().spans().stream()
+ .map(Span::content).reduce("", String::concat);
+ assertTrue(title.contains("elapsed:17ms"), title);
+ }
+
+ private static HistoryEntry historyEntry(String routeId, long elapsed,
boolean last) {
+ HistoryEntry e = new HistoryEntry();
+ e.routeId = routeId;
+ e.elapsed = elapsed;
+ e.last = last;
+ return e;
+ }
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerStartFailureTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerStartFailureTest.java
new file mode 100644
index 000000000000..c659b6448f3f
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerStartFailureTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A Spring Boot app that finds its port in use prints APPLICATION FAILED TO
START, while its JVM can stay up: the TUI
+ * sees it in the output of the launch, so it does not stay "Starting".
+ */
+class LaunchManagerStartFailureTest {
+
+ @TempDir
+ Path dir;
+
+ @Test
+ void aFailedStartIsFoundInTheOutput() throws Exception {
+ Path out = Files.writeString(dir.resolve("launch.log"), "[INFO]
Building metrics 1.0\n");
+ LaunchManager.PendingLaunch launch = new
LaunchManager.PendingLaunch("metrics", null, out, 0);
+ assertThat(launch.startFailed()).isFalse();
+
+ // the marker is written in two parts, between two looks
+ append(out, "***************************\nAPPLICATION FAIL");
+ assertThat(launch.startFailed()).isFalse();
+ append(out, "ED TO START\n***************************\n\nWeb server
failed to start. Port 8080 was already in use.\n");
+ assertThat(launch.startFailed()).isTrue();
+ }
+
+ @Test
+ void aGoodStartIsNotAFailure() throws Exception {
+ Path out = Files.writeString(dir.resolve("launch.log"),
+ "Started MetricsApplication in 2.1 seconds\nApache Camel
4.23.0 (metrics) started in 120ms\n");
+ assertThat(new LaunchManager.PendingLaunch("metrics", null, out,
0).startFailed()).isFalse();
+ assertThat(new LaunchManager.PendingLaunch("metrics", null,
dir.resolve("missing.log"), 0).startFailed()).isFalse();
+ }
+
+ @Test
+ void onceCamelStartedTheLaunchIsNoLongerWatched() throws Exception {
+ // stopping the app later makes Maven print BUILD FAILURE: that is not
a failed start
+ Path out = Files.writeString(dir.resolve("launch.log"), "Tomcat
started on port 8080\n");
+ LaunchManager.PendingLaunch launch = new
LaunchManager.PendingLaunch("metrics", null, out, 0);
+ assertThat(launch.startFailed()).isFalse();
+ assertThat(launch.started).isFalse();
+ append(out, "Apache Camel 4.23.0 (MyCamel) started in 120ms (build:0ms
init:0ms start:120ms)\n");
+ assertThat(launch.startFailed()).isFalse();
+ assertThat(launch.started).isTrue();
+ }
+
+ @Test
+ void aBuildFailureIsAFailedStart() throws Exception {
+ Path out = Files.writeString(dir.resolve("launch.log"), "[ERROR] BUILD
FAILURE\n");
+ assertThat(new LaunchManager.PendingLaunch("metrics", null, out,
0).startFailed()).isTrue();
+ }
+
+ private static void append(Path file, String text) throws Exception {
+ Files.writeString(file, text, StandardOpenOption.APPEND);
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeFindIntegrationTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeFindIntegrationTest.java
new file mode 100644
index 000000000000..b4089a4514ec
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeFindIntegrationTest.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+/**
+ * tui_navigate finds an integration by its pid, its name, or the folder of
the project it was opened as.
+ */
+class McpFacadeFindIntegrationTest {
+
+ @Test
+ void anOpenedProjectIsFoundByItsFolderOnceItRuns() {
+ // opened as "metrics", running under the name its app gives itself
+ IntegrationInfo running = integration("4242", "MyCamel",
"/work/camel-spring-boot-examples/metrics");
+ IntegrationInfo other = integration("4343", "metrics-report",
"/work/other");
+ List<IntegrationInfo> infos = List.of(other, running);
+
+ assertSame(running, McpFacade.findIntegration(infos, "metrics"));
+ assertSame(running, McpFacade.findIntegration(infos, "MyCamel"));
+ assertSame(running, McpFacade.findIntegration(infos, "4242"));
+ assertSame(other, McpFacade.findIntegration(infos, "METRICS-REPORT"));
+ assertNull(McpFacade.findIntegration(infos, "work"));
+ }
+
+ @Test
+ void aNameWinsOverAFolder() {
+ IntegrationInfo byFolder = integration("1", "app", "/work/orders");
+ IntegrationInfo byName = integration("2", "orders", "/work/x");
+ assertSame(byName, McpFacade.findIntegration(List.of(byFolder,
byName), "orders"));
+ }
+
+ @Test
+ void aVanishingIntegrationIsNotFound() {
+ IntegrationInfo gone = integration("1", "orders", "/work/orders");
+ gone.vanishing = true;
+ assertNull(McpFacade.findIntegration(List.of(gone), "orders"));
+ }
+
+ private static IntegrationInfo integration(String pid, String name, String
dir) {
+ IntegrationInfo info = new IntegrationInfo();
+ info.pid = pid;
+ info.name = name;
+ info.directory = dir;
+ return info;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RateEstimatorTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RateEstimatorTest.java
new file mode 100644
index 000000000000..55c2f65ca20c
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RateEstimatorTest.java
@@ -0,0 +1,93 @@
+/*
+ * 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.Set;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class RateEstimatorTest {
+
+ @Test
+ void theRateIsMeasuredWhenTheAppReportsNone() {
+ // a Quarkus app in prod: no load statistics, so 0.00 while 2 messages
a second are processed
+ RateEstimator rates = new RateEstimator();
+ assertEquals("0.00", rates.estimate("1", "0.00", 100, 0), "one sample
is not a rate yet");
+ assertEquals("2.00", rates.estimate("1", "0.00", 102, 1000));
+ assertEquals("2.00", rates.estimate("1", "", 110, 5000));
+ assertNull(rates.estimate("2", null, 7, 0));
+ assertEquals("1.00", rates.estimate("2", null, 8, 1000));
+ }
+
+ @Test
+ void aReportedRateIsKept() {
+ RateEstimator rates = new RateEstimator();
+ rates.estimate("1", "1.00", 100, 0);
+ assertEquals("1.00", rates.estimate("1", "1.00", 150, 1000), "the app
measures it better");
+ }
+
+ @Test
+ void anIdleAppHasNoRate() {
+ RateEstimator rates = new RateEstimator();
+ rates.estimate("1", "0.00", 100, 0);
+ rates.estimate("1", "0.00", 100, 1000);
+ assertEquals("0.00", rates.estimate("1", "0.00", 100, 2000));
+ }
+
+ @Test
+ void theWindowIsTheLastTenSeconds() {
+ RateEstimator rates = new RateEstimator();
+ rates.estimate("1", null, 0, 0);
+ rates.estimate("1", null, 100, 1000);
+ // nothing for a while: the burst falls out of the window
+ rates.estimate("1", null, 100, 20_000);
+ assertEquals("0.00", rates.estimate("1", "0.00", 100, 21_000));
+ }
+
+ @Test
+ void aResetStartsAgain() {
+ RateEstimator rates = new RateEstimator();
+ rates.estimate("1", null, 500, 0);
+ assertNull(rates.estimate("1", null, 3, 1000), "the stats were reset");
+ assertEquals("3.00", rates.estimate("1", null, 6, 2000));
+ }
+
+ @Test
+ void theRoutesGetARateToo() {
+ RateEstimator rates = new RateEstimator();
+ IntegrationInfo info = new IntegrationInfo();
+ info.pid = "42";
+ RouteInfo route = new RouteInfo();
+ route.routeId = "route1";
+ info.routes.add(route);
+
+ info.exchangesTotal = 10;
+ route.total = 10;
+ rates.fill(info, 0);
+ info.exchangesTotal = 14;
+ route.total = 12;
+ rates.fill(info, 2000);
+ assertEquals("2.00", info.throughput);
+ assertEquals("1.00", route.throughput);
+
+ rates.retain(Set.of());
+ assertNull(rates.estimate("42", null, 20, 3000), "a gone integration
starts over");
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParserTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParserTest.java
index 4d4195b58e66..96fa88b2ae72 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParserTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParserTest.java
@@ -21,6 +21,7 @@ import java.util.Map;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -498,4 +499,14 @@ class StatusParserTest {
assertNull(md.exchangeProperties());
assertNull(md.exchangeVariables());
}
+
+ @Test
+ void theBodyOfAMessageIsUnescapedOnce() throws Exception {
+ // MessageHelper escapes the body value, so a JSON body comes as
{\"orderId\":...}
+ JsonObject message = (JsonObject) Jsoner.deserialize("""
+
{"body":{"type":"java.lang.String","value":"{\\\\\\\"orderId\\\\\\\":\\\\\\\"ORD-1003\\\\\\\"}"}}""");
+ assertEquals("{\"orderId\":\"ORD-1003\"}",
StatusParser.parseMessage(message).body());
+ assertEquals("C:\\new", StatusParser.bodyText("C:\\\\new"), "an
escaped backslash stays one backslash");
+ assertNull(StatusParser.bodyText(null));
+ }
}