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)); + } }
