This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch quick-fix/errors-and-timings
in repository https://gitbox.apache.org/repos/asf/camel.git

commit e70d4719b7d132ad31a46e225504c0862e0b5083
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 1 15:58:02 2026 +0200

    chore: camel-jbang - error bodies shown as sent, the elapsed time of the 
whole exchange, a message rate for apps that report none, a failed Spring Boot 
start, an opened project found by its folder
    
    - the body of an error (Errors tab, its diagram, camel_get_errors) and of a 
browsed message is unescaped once when parsed, as the Inspect tab showed it
    - the Inspect title gives the elapsed time of the whole exchange, not of 
the first route called over direct
    - a Quarkus or Spring Boot app in prod (no load statistics) reports 0 msg/s 
while it processes: the rate is measured from the totals
    - a launch is watched for APPLICATION FAILED TO START / BUILD FAILURE: its 
process is stopped and the Failed panel shows why
    - tui_navigate finds an opened project by its folder, once it runs under 
the name of its app
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[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));
+    }
 }

Reply via email to