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 7a87922020eb CAMEL-25425: camel-jbang - the monitor keeps running
examples' files, and says when a start or reload fails (#27516)
7a87922020eb is described below
commit 7a87922020eb0a910770c9f1c50eadc6ff89e00e
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 8 00:36:19 2026 +0200
CAMEL-25425: camel-jbang - the monitor keeps running examples' files, and
says when a start or reload fails (#27516)
* CAMEL-25425: camel-jbang - quitting the monitor keeps the files of the
examples that still run
The examples the monitor starts keep running when it quits, but their files
were deleted when it exited (File.deleteOnExit), so a running example lost
the files it reads, and a dev-mode reload ended with no routes. The folders
were also left behind empty, as deleteOnExit cannot delete a folder that is
not empty: hundreds piled up in the temporary directory.
The monitor now deletes, when it quits, the folders of the examples it
started that no longer run, and the example folders left behind for an hour
or more that no running integration uses. camel run --example deletes its
folder with what the example wrote in it, and keeps it when run with
--background, as the example then outlives the command.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25426: camel-jbang - an infra service that fails to start in the
monitor says why
The monitor started infra services with camel infra run --background, whose
output is lost, so a service that failed to start (the Mosquitto container
cannot bind port 1883 while Artemis has it) was silent: it never appeared,
and an example waiting for it was dropped after 120 seconds with only a
timeout notice. A first start that pulls a large image (Keycloak, Ollama)
also took longer than that, so the example never ran.
The monitor now runs camel infra run itself, in a process that outlives it
as before, and keeps the output: a failed start shows the failure log, and
the example waiting for it is dropped at once with a notice. An example
waits as long as its infra is still starting (at most 15 minutes).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25426: camel-jbang - the monitor's MCP tool that runs an example
says whether it started
It answered "started" as soon as the example was launched, also for an
example that failed a few seconds later (a missing OPENAI_API_KEY), so an
agent never learned that it failed. It now waits up to 30 seconds for Camel
to start, and answers started, failed with the end of the log (without
colors and stack frames, so the cause is in it), or starting.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25426: camel-jbang - an opened project whose run ended without an
app is Stopped at once
An opened project showed Starting for up to 5 minutes when its run ended
before its app showed up. It is now Stopped as soon as the run's process
has ended; a run that failed also shows its failure log, as before.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25426: camel-jbang - the monitor shows a failed reload, as camel ps
does
When a dev-mode reload failed (a file with an error, a file that is gone),
camel ps showed the app as Error with the reason, but the monitor kept
showing it as Running, and a save in the source editor that broke the
reload said only "Saved". The old routes keep running and nothing said so.
The monitor now reads the last reload error from the status: the Overview
shows the app as Error, the info panel says why, a notice tells when a
reload fails, and the Overview table gives the reason to an agent. It clears
when a reload succeeds again.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25426: camel-jbang - the monitor's tables show their rows again
after the list shrinks
A table only scrolled to keep the selected row in view, so when its list
shrank (apps stopped, projects closed) it kept its old scroll offset: after
8 rows became 2 the Overview drew only one, and the other app looked
stopped. The offset is now pulled back so the rows fill the view.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25426: camel-jbang - camel_control in the monitor lists infra
services for an unknown name
Review follow-up of CAMEL-25424: the name can be an infra service, but the
error for an unknown name listed only the integrations. The deferred launch
of an example that needs aws sqs is tested too.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25425: camel-jbang - keep the Javadoc of quitTui on quitTui
Review follow-up: deleteExampleDirs was added between quitTui and its
Javadoc. It now comes after quitTui.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25426: camel-jbang - an infra start that times out in the monitor
is recorded as failed
Review follow-up: every other end of a watched launch records its outcome.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25426: camel-jbang - an infra start that times out in the monitor
shows its failure log
Review follow-up: as the other failed starts do.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../apache/camel/dsl/jbang/core/commands/Run.java | 5 +-
.../camel/dsl/jbang/core/common/ExampleHelper.java | 84 +++++++++-
.../core/common/ExampleHelperStaleDirsTest.java | 68 +++++++++
.../dsl/jbang/core/commands/tui/AbstractTab.java | 16 ++
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 21 +++
.../core/commands/tui/DataRefreshService.java | 25 ++-
.../jbang/core/commands/tui/InfraBrowserPopup.java | 22 +--
.../jbang/core/commands/tui/IntegrationInfo.java | 2 +
.../dsl/jbang/core/commands/tui/LaunchManager.java | 170 +++++++++++++++++++--
.../dsl/jbang/core/commands/tui/McpFacade.java | 12 +-
.../dsl/jbang/core/commands/tui/OverviewTab.java | 15 +-
.../dsl/jbang/core/commands/tui/StatusParser.java | 2 +
.../core/commands/tui/TuiToolDefinitions.java | 2 +
.../jbang/core/commands/tui/TuiToolRegistry.java | 27 +++-
.../core/commands/tui/LaunchManagerInfraTest.java | 62 ++++++++
.../tui/LaunchManagerStartFailureTest.java | 17 +++
.../core/commands/tui/McpFacadeControlTest.java | 12 ++
.../commands/tui/OpenedProjectLifecycleTest.java | 17 +++
.../jbang/core/commands/tui/StatusParserTest.java | 26 ++++
.../core/commands/tui/TableOffsetClampTest.java | 73 +++++++++
20 files changed, 626 insertions(+), 52 deletions(-)
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
index 69dbb782ac58..597bce0c72d4 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
@@ -536,7 +536,8 @@ public class Run extends CamelCommand {
}
private int runBundledExample(JsonObject entry) throws Exception {
- Path tempDir = ExampleHelper.extractBundledExample(entry);
+ // an example run in the background outlives this JVM, so its files
must stay (CAMEL-25425)
+ Path tempDir = ExampleHelper.extractBundledExample(entry, !background);
return runExampleIn(entry, tempDir);
}
@@ -549,7 +550,7 @@ public class Run extends CamelCommand {
Path tempDir;
try {
- tempDir = ExampleHelper.downloadGithubExample(entry);
+ tempDir = ExampleHelper.downloadGithubExample(entry, !background);
} catch (Exception e) {
printer().printErr("Failed to fetch example from GitHub: " +
e.getMessage());
printer().printErr("This example requires an internet
connection.");
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ExampleHelper.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ExampleHelper.java
index bd2449a2fce5..3ffb45a09df4 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ExampleHelper.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ExampleHelper.java
@@ -33,6 +33,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
+import org.apache.camel.util.FileUtil;
import org.apache.camel.util.IOHelper;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
@@ -437,10 +438,23 @@ public final class ExampleHelper {
return new ArrayList<>(files);
}
+ /** The name prefix of the temporary folder an example runs in. */
+ public static final String EXAMPLE_DIR_PREFIX = "camel-example-";
+
public static Path extractBundledExample(JsonObject entry) throws
Exception {
+ return extractBundledExample(entry, true);
+ }
+
+ /**
+ * Extracts a bundled example to a temporary folder of its own.
+ *
+ * @param deleteOnExit whether the folder is deleted when this JVM exits;
false when the example may outlive it, as
+ * the examples the monitor starts do (they keep
running when it quits)
+ */
+ public static Path extractBundledExample(JsonObject entry, boolean
deleteOnExit) throws Exception {
String name = entry.getString("name");
List<String> fileNames = getFiles(entry);
- Path tempDir = Files.createTempDirectory("camel-example-");
+ Path tempDir = Files.createTempDirectory(EXAMPLE_DIR_PREFIX);
for (String fileName : fileNames) {
String resourcePath = "examples/" + name + "/" + fileName;
@@ -451,20 +465,29 @@ public final class ExampleHelper {
// create parent dirs for nested files like
input/account.xml
Files.createDirectories(targetFile.getParent());
Files.writeString(targetFile, content);
- targetFile.toFile().deleteOnExit();
- targetFile.getParent().toFile().deleteOnExit();
}
}
}
- tempDir.toFile().deleteOnExit();
+ if (deleteOnExit) {
+ deleteOnExit(tempDir);
+ }
return tempDir;
}
public static Path downloadGithubExample(JsonObject entry) throws
Exception {
+ return downloadGithubExample(entry, true);
+ }
+
+ /**
+ * Downloads an example from GitHub to a temporary folder of its own.
+ *
+ * @param deleteOnExit whether the folder is deleted when this JVM exits;
false when the example may outlive it
+ */
+ public static Path downloadGithubExample(JsonObject entry, boolean
deleteOnExit) throws Exception {
String name = entry.getString("name");
List<String> fileNames = getFiles(entry);
- Path tempDir = Files.createTempDirectory("camel-example-");
+ Path tempDir = Files.createTempDirectory(EXAMPLE_DIR_PREFIX);
HttpClient hc =
HttpClient.newBuilder().proxy(ProxySelector.getDefault()).build();
for (String fileName : fileNames) {
@@ -476,15 +499,60 @@ public final class ExampleHelper {
Path targetFile = tempDir.resolve(fileName);
Files.createDirectories(targetFile.getParent());
Files.writeString(targetFile, res.body());
- targetFile.toFile().deleteOnExit();
- targetFile.getParent().toFile().deleteOnExit();
}
}
- tempDir.toFile().deleteOnExit();
+ if (deleteOnExit) {
+ deleteOnExit(tempDir);
+ }
return tempDir;
}
+ /**
+ * Deletes the folder of an example, with what the example wrote in it
(out/, a done folder), when this JVM exits.
+ * File.deleteOnExit leaves the folder behind once anything else is in it.
+ */
+ private static void deleteOnExit(Path dir) {
+ Runtime.getRuntime().addShutdownHook(new Thread(() ->
FileUtil.removeDir(dir.toFile()), "CamelExampleCleanup"));
+ }
+
+ /**
+ * The folders of examples in the temporary directory that are older than
the given age and that none of the given
+ * folders (those of running integrations) is in: left behind by runs that
ended, or by a process that was killed.
+ */
+ public static List<Path> staleExampleDirs(Collection<Path> inUse, Duration
minAge) {
+ return staleExampleDirs(Path.of(System.getProperty("java.io.tmpdir")),
inUse, minAge);
+ }
+
+ static List<Path> staleExampleDirs(Path tmp, Collection<Path> inUse,
Duration minAge) {
+ List<Path> answer = new ArrayList<>();
+ List<Path> used = inUse.stream().map(ExampleHelper::realPath).toList();
+ long cutoff = System.currentTimeMillis() - minAge.toMillis();
+ try (var dirs = Files.newDirectoryStream(tmp, EXAMPLE_DIR_PREFIX +
"*")) {
+ for (Path dir : dirs) {
+ if (!Files.isDirectory(dir) ||
Files.getLastModifiedTime(dir).toMillis() > cutoff) {
+ continue;
+ }
+ Path real = realPath(dir);
+ if (used.stream().noneMatch(u -> u.startsWith(real))) {
+ answer.add(dir);
+ }
+ }
+ } catch (Exception e) {
+ // the temporary directory cannot be listed: nothing to clean
+ }
+ return answer;
+ }
+
+ // on macOS the temporary directory is reached through a link (/var is
/private/var)
+ private static Path realPath(Path p) {
+ try {
+ return p.toRealPath();
+ } catch (Exception e) {
+ return p.toAbsolutePath().normalize();
+ }
+ }
+
public static String getGithubUrl(JsonObject entry) {
return GITHUB_EXAMPLES_URL + entry.getString("name");
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ExampleHelperStaleDirsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ExampleHelperStaleDirsTest.java
new file mode 100644
index 000000000000..72af08e0ff5f
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ExampleHelperStaleDirsTest.java
@@ -0,0 +1,68 @@
+/*
+ * 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.common;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The folders of examples left in the temporary directory are found for
cleanup, but never the folder of an example
+ * that still runs, nor one just made for an example that is starting
(CAMEL-25425).
+ */
+class ExampleHelperStaleDirsTest {
+
+ @TempDir
+ Path tmp;
+
+ @Test
+ void anOldFolderNoIntegrationRunsInIsStale() throws Exception {
+ Path left = exampleDir("camel-example-1", Duration.ofHours(2));
+ Path running = exampleDir("camel-example-2", Duration.ofHours(2));
+ Path starting = exampleDir("camel-example-3", Duration.ZERO);
+ Path other = Files.createDirectory(tmp.resolve("something-else"));
+ Files.setLastModifiedTime(other,
FileTime.from(Instant.now().minus(Duration.ofHours(2))));
+
+ List<Path> stale = ExampleHelper.staleExampleDirs(tmp,
List.of(running), Duration.ofHours(1));
+
+ assertThat(stale).containsExactly(left);
+ assertThat(starting).exists();
+ }
+
+ @Test
+ void anIntegrationRunningInASubfolderKeepsTheFolder() throws Exception {
+ Path dir = exampleDir("camel-example-1", Duration.ofHours(2));
+ Path sub = Files.createDirectories(dir.resolve("app"));
+
+ assertThat(ExampleHelper.staleExampleDirs(tmp, List.of(sub),
Duration.ZERO)).isEmpty();
+ }
+
+ private Path exampleDir(String name, Duration age) throws Exception {
+ Path dir = Files.createDirectory(tmp.resolve(name));
+ Files.writeString(dir.resolve("route.camel.yaml"), "- route: {}\n");
+ Files.setLastModifiedTime(dir,
FileTime.from(Instant.now().minus(age)));
+ return dir;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractTab.java
index 3ccc74ce9fe1..8b18bf09c37e 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractTab.java
@@ -142,6 +142,7 @@ abstract class AbstractTab implements MonitorTab {
if (tableArea == null || table == null || tableState == null ||
scrollState == null) {
return;
}
+ clampTableOffset(table, tableArea, tableState, rowCount);
int visibleRows = table.viewportHeight(tableArea);
if (visibleRows <= 0 || rowCount <= visibleRows) {
return;
@@ -158,6 +159,21 @@ abstract class AbstractTab implements MonitorTab {
frame.renderStatefulWidget(Scrollbar.builder().build(), scrollRect,
scrollState);
}
+ /**
+ * Pulls the scroll offset of a table back when rows went away, so the
rows fill the view. The table only scrolls to
+ * keep the selected row in view: when the list shrinks (apps stopped,
projects closed), rows that fit could stay
+ * above the top, and a running app looked gone.
+ */
+ static void clampTableOffset(Table table, Rect tableArea, TableState
tableState, int rowCount) {
+ if (tableArea == null || table == null || tableState == null) {
+ return;
+ }
+ int maxOffset = Math.max(0, rowCount - Math.max(0,
table.viewportHeight(tableArea)));
+ if (tableState.offset() > maxOffset) {
+ tableState.setOffset(maxOffset);
+ }
+ }
+
protected static boolean handleTableClick(MouseEvent me, Rect tableArea,
TableState tableState, int rowCount) {
if (tableArea == null || tableState == null || rowCount <= 0) {
return false;
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index 90441c84e7ef..f59d77b89510 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -582,6 +582,22 @@ public class CamelMonitor extends CamelCommand {
}
}
+ /**
+ * Deletes the folders of the examples that no longer run; an example that
still runs keeps its folder and the files
+ * it reads (CAMEL-25425).
+ */
+ private void deleteExampleDirs() {
+ try {
+ List<Path> runningDirs = ctx.data.get().stream()
+ .filter(i -> !i.vanishing && i.directory != null)
+ .map(i -> Path.of(i.directory))
+ .toList();
+ actionsPopup.getLaunchManager().deleteExampleDirs(runningDirs);
+ } catch (Exception e) {
+ // best effort: what is left is removed by a later session
+ }
+ }
+
/**
* Creates the tab registry, initialises all tabs and wires their data
refresh callbacks.
*/
@@ -1149,6 +1165,7 @@ public class CamelMonitor extends CamelCommand {
webServer.stop();
}
deleteMcpJson(mcpJsonFile);
+ deleteExampleDirs();
this.runner = null;
if (record != null) {
// Only the session that set the properties clears them again
@@ -2058,6 +2075,10 @@ public class CamelMonitor extends CamelCommand {
if (reloadMsg != null) {
setNotification(reloadMsg, false);
}
+ String reloadFailed = dataService.consumeReloadFailedNotification();
+ if (reloadFailed != null) {
+ setNotification(reloadFailed, true);
+ }
Rect contentArea;
if (canvasOverlay.isVisible()) {
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 6a8f90242af2..67e75eaca3dd 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
@@ -437,6 +437,10 @@ class DataRefreshService {
ctx.selectedPid = phantom.pid;
}
phantom.linkedPid = null;
+ if (phantom.startingSince > 0 && phantom.launchedProcess !=
null && !phantom.launchedProcess.isAlive()) {
+ // its run ended before its app showed up: the project is
Stopped, not Starting for minutes
+ phantom.startingSince = 0;
+ }
infos.add(phantom);
}
}
@@ -819,7 +823,16 @@ class DataRefreshService {
// ---- Helpers ----
private void detectReload(IntegrationInfo info) {
- if (info.pid == null || info.reloaded <= 0) {
+ if (info.pid == null) {
+ return;
+ }
+ // a reload that fails (a file with an error, a file that is gone)
leaves the old routes running: say so
+ String prevError = lastReloadError.put(info.pid, info.reloadError !=
null ? info.reloadError : "");
+ if (info.reloadError != null && !info.reloadError.equals(prevError)) {
+ String label = info.name != null ? info.name : info.pid;
+ reloadFailedNotification = label + " reload failed: " +
info.reloadError;
+ }
+ if (info.reloaded <= 0) {
return;
}
Integer prev = lastReloadCount.put(info.pid, info.reloaded);
@@ -829,6 +842,16 @@ class DataRefreshService {
}
}
+ private final Map<String, String> lastReloadError = new
ConcurrentHashMap<>();
+ private volatile String reloadFailedNotification;
+
+ /** Why the latest reload failed, once: the old routes keep running, and
nothing else says so. */
+ String consumeReloadFailedNotification() {
+ String msg = reloadFailedNotification;
+ reloadFailedNotification = null;
+ return msg;
+ }
+
String consumeReloadNotification() {
String msg = reloadNotification;
reloadNotification = null;
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraBrowserPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraBrowserPopup.java
index 8d5d7b7b2355..667e5f03bbdf 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraBrowserPopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraBrowserPopup.java
@@ -18,7 +18,6 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
-import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -50,7 +49,6 @@ import dev.tamboui.widgets.list.ScrollMode;
import dev.tamboui.widgets.paragraph.Paragraph;
import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.catalog.DefaultCamelCatalog;
-import org.apache.camel.dsl.jbang.core.common.LauncherHelper;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
import org.apache.camel.util.json.Jsoner;
@@ -493,24 +491,8 @@ class InfraBrowserPopup {
String portStr = portState != null ? portState.text().trim() : "";
showPortDialog = false;
try {
- List<String> cmd = new
ArrayList<>(LauncherHelper.getCamelCommand());
- cmd.add("infra");
- cmd.add("run");
- cmd.add(alias);
- if (impl != null) {
- cmd.add(impl);
- }
- cmd.add("--background");
- if (!portStr.isEmpty()) {
- cmd.add("--port=" + portStr);
- }
- Path outputFile =
LaunchManager.createSecureTempFile("camel-infra-", ".log");
- outputFile.toFile().deleteOnExit();
- ProcessBuilder pb = new ProcessBuilder(cmd);
- pb.redirectErrorStream(true);
- pb.redirectOutput(outputFile.toFile());
- Process process = pb.start();
- launchManager.addPendingLaunchNoAutoSelect(alias, process,
outputFile);
+ // a start that fails (its port taken) shows why, as the launcher
keeps the output
+ launchManager.startInfra(impl != null ? alias + " " + impl :
alias, portStr);
if (burstCallback != null) {
burstCallback.run();
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
index 6d8b42ff28ee..899da1619885 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
@@ -68,6 +68,8 @@ class IntegrationInfo {
String sinceLastCompleted;
String sinceLastFailed;
int reloaded;
+ // why the last reload failed (dev mode), until a reload succeeds again;
camel ps shows it as Error
+ String reloadError;
String rootLogLevel;
int routeStarted;
int routeTotal;
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 7a13f17aba5f..f5e1bea720dd 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
@@ -23,12 +23,17 @@ import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
+import java.util.Map;
import java.util.Queue;
import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
@@ -38,6 +43,7 @@ import java.util.stream.Collectors;
import org.apache.camel.dsl.jbang.core.common.ExampleHelper;
import org.apache.camel.dsl.jbang.core.common.LauncherHelper;
+import org.apache.camel.util.FileUtil;
import org.apache.camel.util.json.JsonObject;
class LaunchManager {
@@ -84,6 +90,29 @@ class LaunchManager {
* tracked and monitored like an example started from the F2 Actions menu.
Output is redirected to a temporary log
* file. Used by the AI panel's {@code /run} and {@code /infra run} slash
commands.
*/
+ // the folders of the examples started here, with the process that runs
each (camel run, which waits for the app)
+ private final Map<Path, Process> exampleDirs = new ConcurrentHashMap<>();
+
+ /**
+ * Deletes the folders of the examples that are no longer running: those
started here, and those left behind in the
+ * temporary directory for an hour or more (by an earlier session, or a
run that was killed). The folder of an
+ * example that still runs is kept, with the files it reads, as the
example keeps running when the TUI quits.
+ *
+ * @param runningDirs the directories of the running integrations
+ */
+ void deleteExampleDirs(Collection<Path> runningDirs) {
+ Set<Path> unused = new
HashSet<>(ExampleHelper.staleExampleDirs(runningDirs, Duration.ZERO));
+ exampleDirs.forEach((dir, process) -> {
+ // an example that is still being built (exported and packaged by
Maven) is not running yet
+ if (!process.isAlive() && unused.contains(dir)) {
+ FileUtil.removeDir(dir.toFile());
+ }
+ });
+ for (Path dir : ExampleHelper.staleExampleDirs(runningDirs,
Duration.ofHours(1))) {
+ FileUtil.removeDir(dir.toFile());
+ }
+ }
+
void launchDetached(String displayName, List<String> extraArgs) throws
IOException {
JsonObject example = exampleOf(extraArgs);
if (example == null) {
@@ -94,8 +123,11 @@ class LaunchManager {
// where camel run --example would run in the directory of the TUI; a
GitHub example is downloaded first
Thread t = new Thread(() -> {
try {
+ // the example keeps running when the TUI quits, so its folder
is not deleted on exit (CAMEL-25425):
+ // deleteExampleDirs removes it once the example has stopped
Path dir = ExampleHelper.isBundled(example)
- ? ExampleHelper.extractBundledExample(example) :
ExampleHelper.downloadGithubExample(example);
+ ? ExampleHelper.extractBundledExample(example, false)
+ : ExampleHelper.downloadGithubExample(example, false);
start(displayName, exampleArgs(extraArgs, example), dir);
} catch (Exception e) {
notify("Failed to start: " + displayName + " - " +
e.getMessage(), true);
@@ -117,6 +149,9 @@ class LaunchManager {
pb.redirectErrorStream(true);
pb.redirectOutput(outputFile.toFile());
Process process = pb.start();
+ if (dir != null) {
+ exampleDirs.put(dir, process);
+ }
addPendingLaunch(displayName, process, outputFile);
}
@@ -173,10 +208,6 @@ class LaunchManager {
launched.clear();
}
- void addPendingLaunchNoAutoSelect(String name, Process process, Path
outputFile) {
- pendingLaunches.add(new PendingLaunch(name, process, outputFile,
System.currentTimeMillis()));
- }
-
void tick(long now) {
monitorPendingLaunches(now);
checkDeferredLaunch(now);
@@ -216,23 +247,52 @@ class LaunchManager {
}
/**
- * Starts an infra service in the background via {@code camel infra run
<alias> --background}, where the alias may
- * name the implementation too ({@code aws sqs}). The launch is monitored
like any other, so a failure surfaces
- * through the failure log callback.
+ * Starts an infra service via {@code camel infra run <alias>}, where the
alias may name the implementation too
+ * ({@code aws sqs}). It runs in a process of its own, which outlives the
TUI, as {@code --background} would start
+ * it; but the TUI keeps its output, so a service that fails to start (its
port taken, its image not pulled) shows
+ * why through the failure log callback, where {@code --background} would
lose it.
*/
void startInfra(String alias) throws IOException {
+ startInfra(alias, null);
+ }
+
+ /** Starts an infra service as {@link #startInfra(String)} does, on the
given port (none: its default). */
+ void startInfra(String alias, String port) throws IOException {
List<String> cmd = new ArrayList<>(LauncherHelper.getCamelCommand());
cmd.add("infra");
cmd.add("run");
cmd.addAll(Arrays.asList(alias.trim().split("\\s+")));
- cmd.add("--background");
+ if (port != null && !port.isBlank()) {
+ cmd.add("--port=" + port.trim());
+ }
+ launchInfra(alias, cmd);
+ }
+
+ /** Runs the command that starts the given infra service, and watches it
until the service is up. */
+ void launchInfra(String alias, List<String> cmd) throws IOException {
Path outputFile = createSecureTempFile("camel-infra-", ".log");
outputFile.toFile().deleteOnExit();
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.redirectErrorStream(true);
pb.redirectOutput(outputFile.toFile());
Process process = pb.start();
- pendingLaunches.add(new PendingLaunch(alias, process, outputFile,
System.currentTimeMillis()));
+ PendingLaunch pl = new PendingLaunch(alias, process, outputFile,
System.currentTimeMillis());
+ pl.infraAlias = serviceOf(alias);
+ pendingLaunches.add(pl);
+ }
+
+ /**
+ * Whether an infra service of the given ones is still starting: its camel
infra run lives, and it is not up yet.
+ */
+ private boolean infraStarting(List<String> aliases) {
+ Set<String> services =
aliases.stream().map(LaunchManager::serviceOf).collect(Collectors.toSet());
+ return pendingLaunches.stream()
+ .anyMatch(pl -> pl.infraAlias != null &&
services.contains(pl.infraAlias) && pl.process.isAlive());
+ }
+
+ /** Runs the launch once the given infra services are up, or drops it when
they fail to start. */
+ void deferUntilInfra(List<String> infra, String displayName, Runnable
launchAction) {
+ deferredLaunch = new DeferredLaunch(displayName, infra,
System.currentTimeMillis(), launchAction);
}
void startMissingInfraAndDefer(List<String> missingInfra, String
displayName, Runnable launchAction) {
@@ -244,7 +304,7 @@ class LaunchManager {
return;
}
}
- deferredLaunch = new DeferredLaunch(displayName, missingInfra,
System.currentTimeMillis(), launchAction);
+ deferUntilInfra(missingInfra, displayName, launchAction);
if (infraCatalogClearer != null) {
infraCatalogClearer.run();
}
@@ -358,7 +418,13 @@ class LaunchManager {
DeferredLaunch dl = deferredLaunch;
deferredLaunch = null;
dl.launchAction.run();
- } else if (now - deferredLaunch.startTime > 120_000) {
+ } else if (!infraStarting(deferredLaunch.requiredInfra)) {
+ // the infra failed to start: the failure log says why
+ DeferredLaunch dl = deferredLaunch;
+ deferredLaunch = null;
+ notify("Not started: " + dl.displayName() + " (its infra
services failed to start)", true);
+ } else if (now - deferredLaunch.startTime > INFRA_WATCH_MS) {
+ // a first start pulls the container image, which can take
minutes: wait while the infra is starting
deferredLaunch = null;
notify("Timeout waiting for infra services to start", true);
}
@@ -366,30 +432,59 @@ class LaunchManager {
}
private void monitorPendingLaunches(long now) {
+ Set<String> runningAliases = null;
Iterator<PendingLaunch> it = pendingLaunches.iterator();
while (it.hasNext()) {
PendingLaunch pl = it.next();
+ if (pl.infraAlias != null && !pl.started) {
+ if (runningAliases == null) {
+ runningAliases = infraServices.get().stream()
+ .filter(i -> i.alive).map(i ->
i.alias).collect(Collectors.toSet());
+ }
+ // an infra service has started when it is up
+ if (runningAliases.contains(pl.infraAlias)) {
+ pl.started = true;
+ notify("Started: " + pl.name, false);
+ pl.announced = true;
+ }
+ }
if (!pl.process.isAlive()) {
int exitCode = pl.process.exitValue();
- if (exitCode == 0 || pl.started) {
+ boolean ok = pl.infraAlias != null ? pl.started : exitCode ==
0 || pl.started;
+ if (ok) {
if (!pl.announced) {
notify("Started: " + pl.name, false);
}
- } else if (failureLogCallback != null) {
- failureLogCallback.accept(pl.name, pl.outputFile);
+ outcomes.put(pl.name, LaunchOutcome.started());
+ } else {
+ outcomes.put(pl.name, LaunchOutcome.failed(pl.outputFile));
+ if (failureLogCallback != null) {
+ failureLogCallback.accept(pl.name, pl.outputFile);
+ }
}
it.remove();
} else if (pl.started) {
// up and running: a stop or a failure from now on is not a
failed start
+ outcomes.put(pl.name, LaunchOutcome.started());
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();
+ outcomes.put(pl.name, LaunchOutcome.failed(pl.outputFile));
if (failureLogCallback != null) {
failureLogCallback.accept(pl.name, pl.outputFile);
}
it.remove();
+ } else if (pl.infraAlias != null) {
+ if (now - pl.startTime > INFRA_WATCH_MS) {
+ // not up after the longest an image pull should take: a
failed start, as the other ends record
+ outcomes.put(pl.name, LaunchOutcome.failed(pl.outputFile));
+ if (failureLogCallback != null) {
+ failureLogCallback.accept(pl.name, pl.outputFile);
+ }
+ it.remove();
+ }
} else {
if (!pl.announced && now - pl.startTime > 8000) {
notify("Started: " + pl.name, false);
@@ -414,6 +509,49 @@ class LaunchManager {
*/
static final long WATCH_MS = 5 * 60_000;
+ // how the latest launch of each name went, for an agent that asks
(tui_run_example waits for it)
+ private final Map<String, LaunchOutcome> outcomes = new
ConcurrentHashMap<>();
+
+ /**
+ * How a launch went: started (Camel said so), or failed with the end of
its output; null while it is starting.
+ */
+ record LaunchOutcome(boolean ok, String log) {
+
+ private static final Pattern ANSI =
Pattern.compile("\u001B\\[[0-9;]*m");
+
+ static LaunchOutcome started() {
+ return new LaunchOutcome(true, null);
+ }
+
+ static LaunchOutcome failed(Path outputFile) {
+ String tail = null;
+ try {
+ // without colors and stack frames: the messages and their
causes are what tell why
+ List<String> lines = Files.readAllLines(outputFile,
StandardCharsets.UTF_8).stream()
+ .map(l -> ANSI.matcher(l).replaceAll(""))
+ .filter(l -> !l.stripLeading().startsWith("at ") &&
!l.stripLeading().startsWith("... "))
+ .toList();
+ tail = String.join("\n", lines.subList(Math.max(0,
lines.size() - 30), lines.size()));
+ } catch (Exception e) {
+ // no output to show
+ }
+ return new LaunchOutcome(false, tail);
+ }
+ }
+
+ /** Forgets how the last launch of the given name went, before it is
launched again. */
+ void clearOutcome(String name) {
+ outcomes.remove(name);
+ }
+
+ /** How the latest launch of the given name went, or null while it is
still starting. */
+ LaunchOutcome outcome(String name) {
+ return outcomes.get(name);
+ }
+
+ /** How long an infra service may take to start: the first start pulls its
container image. */
+ static final long INFRA_WATCH_MS = 15 * 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");
@@ -427,6 +565,8 @@ class LaunchManager {
final Process process;
final Path outputFile;
final long startTime;
+ // the service of an infra launch: it has started once the service is
up; null for an integration
+ String infraAlias;
boolean announced;
// Camel said it started: the launch is no longer watched for a failed
start
boolean started;
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 7bec98b44e7c..c2859f6e976c 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
@@ -29,7 +29,6 @@ import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
-import java.util.stream.Collectors;
import dev.tamboui.buffer.Buffer;
import dev.tamboui.export.ExportRequest;
@@ -1549,8 +1548,15 @@ class McpFacade {
if (nameOrPid != null && !nameOrPid.isBlank()) {
String pid = findPidByNameOrPid(nameOrPid.trim());
if (pid == null) {
- String names = data.get().stream().filter(i -> !i.vanishing)
- .map(i -> i.name + " (pid " + i.pid +
")").collect(Collectors.joining(", "));
+ List<String> known = new ArrayList<>();
+ data.get().stream().filter(i -> !i.vanishing).forEach(i ->
known.add(i.name + " (pid " + i.pid + ")"));
+ List<InfraInfo> infras = ctx.infraData != null ?
ctx.infraData.get() : null;
+ if (infras != null) {
+ // infra services can be named too
+ infras.stream().filter(i -> !i.vanishing)
+ .forEach(i -> known.add(i.alias + " (infra, pid "
+ i.pid + ")"));
+ }
+ String names = String.join(", ", known);
return "Error: no integration with name or pid " + nameOrPid
+ (names.isEmpty() ? "; none is running" : ". Known: "
+ names);
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
index 5b9f4b10696b..abd201df7753 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
@@ -346,6 +346,7 @@ class OverviewTab extends AbstractTab {
lastTableArea = chunks.get(0);
TableState renderState = infraFocused ? new TableState() : tableState;
+ clampTableOffset(table, chunks.get(0), renderState, integrationCount);
frame.renderStatefulWidget(table, chunks.get(0), renderState);
renderTableScrollbar(frame, lastTableArea, table, tableState,
tableScrollState, integrationCount);
@@ -569,9 +570,13 @@ class OverviewTab extends AbstractTab {
} else if ("Running".equals(stateText) && info.routeStarted ==
0 && info.routeTotal > 0) {
stateText = "Stopped";
}
+ if (info.reloadError != null && !"Stopping".equals(stateText))
{
+ // its last reload failed, as camel ps shows it; the info
panel says why
+ stateText = "Error";
+ }
Style statusStyle = switch (stateText) {
case "Started", "Running" -> Theme.success();
- case "Stopped" -> Theme.error();
+ case "Stopped", "Error" -> Theme.error();
default -> Theme.warning();
};
@@ -986,6 +991,11 @@ class OverviewTab extends AbstractTab {
}
lines.add(Line.from(profileSpans));
}
+ if (sel.reloadError != null) {
+ // the old routes keep running; the next save that loads
reloads them
+ lines.add(Line.from(Span.styled(
+ TuiHelper.truncate("Reload failed: " +
sel.reloadError, inner.width()), Theme.error())));
+ }
lines.add(Line.from(Span.raw("")));
if (sel.javaVersion != null) {
lines.add(Line.from(
@@ -1446,6 +1456,9 @@ class OverviewTab extends AbstractTab {
row.put("camelVersion", info.camelVersion);
row.put("platform", info.platform);
row.put("state", info.state);
+ if (info.reloadError != null) {
+ row.put("reloadError", info.reloadError);
+ }
row.put("ready", info.ready);
// info.uptime holds the process start time (epoch millis), which
the screen shows as an elapsed
// duration; export the same duration plus the raw values with
unambiguous names so an AI reading
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 9df917f0bdc1..d9db4a4cc70e 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
@@ -201,6 +201,8 @@ final class StatusParser {
Map<String, ?> reloadStats = (Map<String, ?>) stats.get("reload");
if (reloadStats != null) {
info.reloaded = (int) objToLong(reloadStats.get("reloaded"));
+ Map<String, ?> lastError = (Map<String, ?>)
reloadStats.get("lastError");
+ info.reloadError = lastError != null ? (String)
lastError.get("message") : null;
}
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
index 5a80bfdb1577..10b3675274d5 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
@@ -529,6 +529,8 @@ final class TuiToolDefinitions {
"Launches a named bundled example as a background process. "
+ "Bypasses the F2 menu entirely — no UI
navigation needed. "
+ "Automatically starts required infra
services (Docker containers) if needed. "
+ + "Waits up to 30 seconds for the example
to start: status started, failed (with "
+ + "the end of its log), starting, or
starting_infra. "
+ "Use tui_list_examples to discover
available example names.",
Map.of("name", propDef("string",
"Example name from the catalog (e.g.
'quick-start/timer-log')"),
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
index 36dd757d9f8a..0e8d17252930 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
@@ -1910,15 +1910,38 @@ class TuiToolRegistry {
}
List<String> camelArgs = buildExampleArgs(name, args);
+ lm.clearOutcome(name);
lm.launchDetached(name, camelArgs);
+ // an example can fail a few seconds after it is launched (a missing
API key, a bad route): wait for Camel to
+ // say it started, or for the failure, so the agent learns how it went
+ LaunchManager.LaunchOutcome outcome = null;
+ long deadline = System.currentTimeMillis() + RUN_EXAMPLE_WAIT_MS;
+ while ((outcome = lm.outcome(name)) == null &&
System.currentTimeMillis() < deadline) {
+ Thread.sleep(250);
+ }
JsonObject result = new JsonObject();
- result.put("status", "started");
- result.put("message", "Started: " + name);
result.put("name", name);
+ if (outcome == null) {
+ result.put("status", "starting");
+ result.put("message", "Still starting: " + name + " (a first run
downloads its dependencies); "
+ + "tui_get_options lists it once it runs");
+ } else if (outcome.ok()) {
+ result.put("status", "started");
+ result.put("message", "Started: " + name);
+ } else {
+ result.put("status", "failed");
+ result.put("message", "Failed to start: " + name);
+ if (outcome.log() != null) {
+ result.put("log", outcome.log());
+ }
+ }
return Jsoner.serialize(result);
}
+ // how long tui_run_example waits for an example to start or fail
+ static final long RUN_EXAMPLE_WAIT_MS = 30_000;
+
private static List<String> buildExampleArgs(String name, Map<String,
Object> args) {
List<String> camelArgs = new ArrayList<>();
camelArgs.add("run");
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerInfraTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerInfraTest.java
index 88949e47f7db..e3befe7d9ff0 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerInfraTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerInfraTest.java
@@ -18,6 +18,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
import java.util.ArrayList;
import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.camel.util.json.JsonObject;
import org.junit.jupiter.api.Test;
@@ -50,6 +51,67 @@ class LaunchManagerInfraTest {
assertThat(lm.findMissingInfraServices(example)).containsExactly("kafka");
}
+ @Test
+ void aLaunchWaitsWhileItsInfraIsStartingAndRunsOnceItIsUp() throws
Exception {
+ List<String> notices = new ArrayList<>();
+ lm.setNotificationCallback((msg, error) -> notices.add(msg));
+ AtomicBoolean launched = new AtomicBoolean();
+ // a first start pulls the image: the infra takes a while
+ lm.launchInfra("kafka", List.of("sleep", "30"));
+ lm.deferUntilInfra(List.of("kafka"), "kafka-orders", () ->
launched.set(true));
+
+ lm.tick(System.currentTimeMillis());
+ assertThat(launched).isFalse();
+ assertThat(notices).isEmpty();
+
+ running.add(infra("kafka"));
+ lm.tick(System.currentTimeMillis());
+ assertThat(launched).isTrue();
+ assertThat(notices).contains("Started: kafka");
+ ProcessHandle.current().children()
+ .filter(p -> p.info().command().map(c ->
c.endsWith("sleep")).orElse(false))
+ .forEach(ProcessHandle::destroy);
+ }
+
+ @Test
+ void aLaunchThatNeedsAServiceWithItsImplementationRunsOnceTheServiceIsUp()
throws Exception {
+ AtomicBoolean launched = new AtomicBoolean();
+ // camel infra run aws sqs runs as the aws service
+ lm.launchInfra("aws sqs", List.of("sleep", "30"));
+ lm.deferUntilInfra(List.of("aws sqs"), "aws-sqs", () ->
launched.set(true));
+
+ lm.tick(System.currentTimeMillis());
+ assertThat(launched).isFalse();
+
+ running.add(infra("aws"));
+ lm.tick(System.currentTimeMillis());
+ assertThat(launched).isTrue();
+ ProcessHandle.current().children()
+ .filter(p -> p.info().command().map(c ->
c.endsWith("sleep")).orElse(false))
+ .forEach(ProcessHandle::destroy);
+ }
+
+ @Test
+ void aLaunchIsDroppedWhenItsInfraFailsToStart() throws Exception {
+ List<String> notices = new ArrayList<>();
+ List<String> failures = new ArrayList<>();
+ lm.setNotificationCallback((msg, error) -> notices.add(msg));
+ lm.setFailureLogCallback((name, log) -> failures.add(name));
+ AtomicBoolean launched = new AtomicBoolean();
+ // the container cannot bind its port: camel infra run ends with an
error
+ lm.launchInfra("mosquitto", List.of("sh", "-c", "echo port is already
allocated; exit 1"));
+ lm.deferUntilInfra(List.of("mosquitto"), "mqtt", () ->
launched.set(true));
+ long deadline = System.currentTimeMillis() + 10_000;
+ while (failures.isEmpty() && System.currentTimeMillis() < deadline) {
+ lm.tick(System.currentTimeMillis());
+ Thread.sleep(50);
+ }
+
+ assertThat(launched).isFalse();
+ assertThat(failures).containsExactly("mosquitto");
+ assertThat(notices).contains("Not started: mqtt (its infra services
failed to start)");
+ }
+
private static InfraInfo infra(String alias) {
InfraInfo info = new InfraInfo();
info.alias = alias;
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
index c659b6448f3f..150fd59b360e 100644
---
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
@@ -73,6 +73,23 @@ class LaunchManagerStartFailureTest {
assertThat(new LaunchManager.PendingLaunch("metrics", null, out,
0).startFailed()).isTrue();
}
+ @Test
+ void aFailedLaunchTellsWhyWithoutColorsAndStackFrames() throws Exception {
+ Path out = Files.writeString(dir.resolve("launch.log"),
+ "\u001B[32m INFO\u001B[m Starting\n"
+ + "ERROR Error
starting Camel: Property with key [env:OPENAI_API_KEY] returned null\n"
+ + "\tat
org.apache.camel.Foo.bar(Foo.java:1)\n"
+ + "Caused by:
java.lang.IllegalArgumentException: no key\n"
+ + "\t... 12
more\n");
+
+ LaunchManager.LaunchOutcome outcome =
LaunchManager.LaunchOutcome.failed(out);
+
+ assertThat(outcome.ok()).isFalse();
+ assertThat(outcome.log()).isEqualTo(" INFO Starting\n"
+ + "ERROR Error starting Camel:
Property with key [env:OPENAI_API_KEY] returned null\n"
+ + "Caused by:
java.lang.IllegalArgumentException: no key");
+ }
+
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/McpFacadeControlTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeControlTest.java
index bb24bc6f384d..dbd892585af9 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeControlTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeControlTest.java
@@ -82,6 +82,18 @@ class McpFacadeControlTest {
assertThat(calls).isEmpty();
}
+ @Test
+ void anUnknownNameAlsoListsTheInfraServices() {
+ integration("orders", "100");
+ InfraInfo kafka = new InfraInfo();
+ kafka.alias = "kafka";
+ kafka.pid = "300";
+ infra.get().add(kafka);
+
+ assertThat(facade.controlIntegration("stop", "kafak"))
+ .contains("orders (pid 100)").contains("kafka (infra, pid
300)");
+ }
+
@Test
void withoutANameTheOnlyOneRunningIsUsed() {
integration("orders", "100");
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OpenedProjectLifecycleTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OpenedProjectLifecycleTest.java
index 90c51c49ff98..804241b47acf 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OpenedProjectLifecycleTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OpenedProjectLifecycleTest.java
@@ -92,6 +92,23 @@ class OpenedProjectLifecycleTest {
assertTrue(merge(other).contains(project));
}
+ @Test
+ void aProjectWhoseRunEndedWithoutAnAppIsStoppedAtOnce() throws Exception {
+ IntegrationInfo project = new IntegrationInfo();
+ project.name = "orders";
+ project.sourceDir = "/work/orders";
+ project.startingSince = System.currentTimeMillis();
+ // camel run pom.xml ended (its Maven build failed) before the app
showed up
+ Process run = new ProcessBuilder("true").start();
+ run.waitFor();
+ project.launchedProcess = run.toHandle();
+ ctx.addPhantom(project);
+
+ merge();
+
+ assertEquals(0, project.startingSince);
+ }
+
@Test
void aProjectIsStartingUntilItsAppShowsUp() {
IntegrationInfo project = new IntegrationInfo();
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 96fa88b2ae72..4ccc52eff20a 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
@@ -52,6 +52,32 @@ class StatusParserTest {
assertEquals(5, info.state);
}
+ @Test
+ void aFailedReloadIsReadAsCamelPsReadsIt() {
+ JsonObject root = new JsonObject();
+ JsonObject context = new JsonObject();
+ context.put("name", "routes");
+ JsonObject stats = new JsonObject();
+ JsonObject reload = new JsonObject();
+ reload.put("reloaded", 2);
+ reload.put("failed", 1);
+ JsonObject lastError = new JsonObject();
+ lastError.put("message", "Greeter.java does not exist");
+ reload.put("lastError", lastError);
+ stats.put("reload", reload);
+ context.put("statistics", stats);
+ root.put("context", context);
+
+ IntegrationInfo info =
StatusParser.parseIntegration(ProcessHandle.current(), root);
+
+ assertEquals(2, info.reloaded);
+ assertEquals("Greeter.java does not exist", info.reloadError);
+
+ // a reload that succeeds again clears it
+ reload.remove("lastError");
+ assertNull(StatusParser.parseIntegration(ProcessHandle.current(),
root).reloadError);
+ }
+
@Test
void parseIntegrationReturnsNullWhenNoContext() {
JsonObject root = new JsonObject();
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TableOffsetClampTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TableOffsetClampTest.java
new file mode 100644
index 000000000000..1b96a5320ac6
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TableOffsetClampTest.java
@@ -0,0 +1,73 @@
+/*
+ * 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.ArrayList;
+import java.util.List;
+
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.table.Row;
+import dev.tamboui.widgets.table.Table;
+import dev.tamboui.widgets.table.TableState;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A table whose list shrinks shows its rows from the top again: after 8 apps
became 2, the first stayed above the top
+ * of the Overview, and looked stopped.
+ */
+class TableOffsetClampTest {
+
+ @Test
+ void theOffsetIsPulledBackWhenRowsGoAway() {
+ Rect area = new Rect(0, 0, 40, 6);
+ TableState state = new TableState();
+ state.setOffset(6);
+
+ AbstractTab.clampTableOffset(table(2), area, state, 2);
+
+ assertThat(state.offset()).isZero();
+ }
+
+ @Test
+ void anOffsetThatStillFitsIsKept() {
+ Rect area = new Rect(0, 0, 40, 6);
+ Table table = table(20);
+ int visible = table.viewportHeight(area);
+ TableState state = new TableState();
+ state.setOffset(5);
+
+ AbstractTab.clampTableOffset(table, area, state, 20);
+ assertThat(state.offset()).isEqualTo(5);
+
+ state.setOffset(19);
+ AbstractTab.clampTableOffset(table, area, state, 20);
+ assertThat(state.offset()).isEqualTo(20 - visible);
+ }
+
+ private static Table table(int rows) {
+ List<Row> list = new ArrayList<>();
+ for (int i = 0; i < rows; i++) {
+ list.add(Row.from("app-" + i));
+ }
+ return
Table.builder().rows(list).header(Row.from("NAME")).widths(Constraint.fill())
+ .block(Block.bordered()).build();
+ }
+}