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 4a9d3f170c74 CAMEL-24202: camel-tui accessible from a web browser via
--web
4a9d3f170c74 is described below
commit 4a9d3f170c74a4f7cf1a8d2b236fd20204aa3d36
Author: Adriano Machado <[email protected]>
AuthorDate: Tue Aug 4 16:22:10 2026 -0400
CAMEL-24202: camel-tui accessible from a web browser via --web
Adds an opt-in --web/--web-port flag to `camel tui monitor` that serves
the live TUI dashboard to a browser over WebSocket using TamboUI's Aesh
backend. Each browser connection gets its own independent CamelMonitor
session. Binds loopback-only (127.0.0.1), rejects cross-origin WebSocket
upgrades, and prevents browser sessions from quitting the host process.
Includes vendored xterm.js assets with a Camel-branded page, bounded
session executor with clean rejection handling, X-Frame-Options/CSP
headers, graceful session drain on shutdown, and security-model.adoc
documentation alongside the existing --mcp posture.
Closes #25317
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Co-Authored-By: Codex <[email protected]>
---
.../modules/ROOT/pages/camel-jbang-tui.adoc | 27 ++
.../modules/ROOT/pages/security-model.adoc | 17 +-
dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml | 59 +++++
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 54 +++-
.../jbang/core/commands/tui/TuiBackendHelper.java | 16 +-
.../dsl/jbang/core/commands/tui/TuiCommand.java | 16 ++
.../dsl/jbang/core/commands/tui/TuiWebServer.java | 264 +++++++++++++++++++
.../main/resources/tui/web/images/camel-logo.png | Bin 0 -> 22001 bytes
.../src/main/resources/tui/web/index.html | 288 +++++++++++++++++++++
.../resources/tui/web/vendor/LICENSE-xterm.txt | 35 +++
.../resources/tui/web/vendor/xterm-addon-fit.js | 2 +
.../src/main/resources/tui/web/vendor/xterm.css | 185 +++++++++++++
.../src/main/resources/tui/web/vendor/xterm.js | 2 +
.../jbang/core/commands/tui/TuiWebServerTest.java | 235 +++++++++++++++++
parent/pom.xml | 1 +
15 files changed, 1189 insertions(+), 12 deletions(-)
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
index 5ce703da222e..735d8be08271 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
@@ -760,6 +760,25 @@ The MCP server exposes tools organized by purpose:
See xref:camel-jbang-mcp.adoc[Camel MCP Server] for more about MCP and AI
integration with Camel.
+== Web Browser Access
+
+The TUI can also be reached from a web browser on the same host, using the
same dashboard you'd see in a
+local terminal -- useful when you prefer a browser session to a terminal
window.
+
+[source,bash]
+----
+camel tui --web
+----
+
+This starts a web terminal server on `localhost:8090` (configurable with
`--web-port`).
+Open `http://localhost:8090` in a browser to get a full xterm.js terminal
driving the same
+TUI dashboard, with the same tabs, keyboard shortcuts, and F2 actions menu as
a local session.
+
+Like the MCP server, the web server is bound to `127.0.0.1` only -- it never
listens on
+external interfaces -- and there is no authentication beyond that. Each
browser connection
+gets its own independent TUI session (its own process discovery and navigation
state), the
+same as if you'd run `camel tui` again in a new terminal.
+
== Recording Demos
The TUI can record terminal sessions for demos and documentation.
@@ -809,6 +828,14 @@ vhs demo.tape # .tape -> .gif
| Port for the embedded MCP server.
| `8123`
+| `--web`
+| Enable the browser-accessible terminal (WebSocket) server.
+| `false`
+
+| `--web-port`
+| Port for the web terminal server.
+| `8090`
+
| `--refresh`
| Screen refresh interval in milliseconds.
| `100`
diff --git a/docs/user-manual/modules/ROOT/pages/security-model.adoc
b/docs/user-manual/modules/ROOT/pages/security-model.adoc
index 67be43f6a879..4c7f867dbeea 100644
--- a/docs/user-manual/modules/ROOT/pages/security-model.adoc
+++ b/docs/user-manual/modules/ROOT/pages/security-model.adoc
@@ -641,6 +641,17 @@ be closed as `not a vulnerability`.
"MBean operation X executes code or sends to endpoint Y when invoked
from a JMX or Jolokia connection" describes the documented contract,
not a framework vulnerability.
+* *The Camel TUI's `--mcp` and `--web` servers.* Camel TUI
+ (`dsl/camel-jbang/camel-jbang-plugin-tui`) can optionally expose an MCP
+ server (`--mcp`, for AI-agent access) and a browser-accessible terminal
+ (`--web`, over WebSocket). Both are management surfaces under the same
+ framing as above: opt-in (off unless the flag is passed), bound to
+ `127.0.0.1` only, and - like JMX and Jolokia without an auth layer
+ configured - carry no authentication of their own beyond that loopback
+ bind. Full interactive control of the TUI (including anything it can
+ shell out to, such as `docker`/`podman`/`camel run`) reachable from
+ `127.0.0.1` is the documented contract for both flags, not a framework
+ vulnerability.
* *Vulnerabilities in third-party transitive dependencies.* Camel fixes
CVEs in Camel code, not in third-party JARs; the report belongs
upstream. Camel may upgrade the dependency to pick up an upstream fix,
@@ -1084,8 +1095,10 @@ YAML::
upstream broker is not inside the trust boundary. If the option is
unavoidable, install an `ObjectInputFilter`.
* *Do not expose management surfaces.* `camel-management`, the developer
- console, `camel-jolokia` and JMX should listen on a loopback interface, a
- sidecar, or a separate network only.
+ console, `camel-jolokia`, JMX, and Camel TUI's `--mcp` / `--web` servers
+ should listen on a loopback interface, a sidecar, or a separate network
+ only. The TUI servers already default to `127.0.0.1`; do not front them
+ with a reverse proxy that makes them reachable from a public network.
* *Keep components patched.* Pin Camel to a supported version, subscribe to
the announce list, and respond to advisories at
link:/security/[].
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
index b9d2a2a3f2fe..9f68638c6b2f 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
@@ -87,6 +87,34 @@
<artifactId>tamboui-markdown</artifactId>
<version>${tamboui-version}</version>
</dependency>
+ <dependency>
+ <groupId>dev.tamboui</groupId>
+ <artifactId>tamboui-aesh-backend</artifactId>
+ <version>${tamboui-version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.aesh</groupId>
+ <artifactId>terminal-http</artifactId>
+ <version>${aesh-terminal-version}</version>
+ </dependency>
+ <!-- Netty is pulled in transitively by terminal-http, but
TuiWebServer uses these
+ packages directly, so declare them explicitly to keep the
contract clear. -->
+ <dependency>
+ <groupId>io.netty</groupId>
+ <artifactId>netty-common</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>io.netty</groupId>
+ <artifactId>netty-transport</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>io.netty</groupId>
+ <artifactId>netty-handler</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>io.netty</groupId>
+ <artifactId>netty-codec-http</artifactId>
+ </dependency>
<dependency>
<groupId>org.apache.camel</groupId>
<artifactId>camel-test-junit6</artifactId>
@@ -105,4 +133,35 @@
</dependency>
</dependencies>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.rat</groupId>
+ <artifactId>apache-rat-plugin</artifactId>
+ <configuration>
+ <excludes combine.children="append">
+ <!-- vendored MIT-licensed xterm.js assets; see
src/main/resources/tui/web/vendor/LICENSE-xterm.txt -->
+ <exclude>**/tui/web/vendor/*.js</exclude>
+ <exclude>**/tui/web/vendor/*.css</exclude>
+ </excludes>
+ </configuration>
+ </plugin>
+ <plugin>
+ <groupId>com.mycila</groupId>
+ <artifactId>license-maven-plugin</artifactId>
+ <configuration>
+ <licenseSets>
+ <licenseSet>
+ <excludes combine.children="append">
+ <!-- vendored MIT-licensed xterm.js assets;
see src/main/resources/tui/web/vendor/LICENSE-xterm.txt -->
+ <exclude>**/tui/web/vendor/*.js</exclude>
+ <exclude>**/tui/web/vendor/*.css</exclude>
+ </excludes>
+ </licenseSet>
+ </licenseSets>
+ </configuration>
+ </plugin>
+ </plugins>
+ </build>
+
</project>
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 9a03b3c6244f..b4bea74bd1fd 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
@@ -20,6 +20,7 @@ import java.io.File;
import java.io.IOException;
import java.lang.System.Logger;
import java.lang.System.Logger.Level;
+import java.net.BindException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -40,6 +41,7 @@ import dev.tamboui.layout.Layout;
import dev.tamboui.layout.Rect;
import dev.tamboui.style.Color;
import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Backend;
import dev.tamboui.terminal.Frame;
import dev.tamboui.text.CharWidth;
import dev.tamboui.text.Line;
@@ -108,6 +110,15 @@ public class CamelMonitor extends CamelCommand {
defaultValue = "8123")
int mcpPort = 8123;
+ @CommandLine.Option(names = { "--web" },
+ description = "Enable browser-accessible terminal
(WebSocket) server")
+ boolean web;
+
+ @CommandLine.Option(names = { "--web-port" },
+ description = "Web terminal server port (default:
${DEFAULT-VALUE})",
+ defaultValue = "8090")
+ int webPort = 8090;
+
@CommandLine.Option(names = { "--theme" },
description = "Color theme (overrides persisted
preference for this session)",
completionCandidates =
ThemeModeCompletionCandidates.class)
@@ -127,6 +138,7 @@ public class CamelMonitor extends CamelCommand {
private String lastWaveNotification;
private boolean mcpInjectedKey;
private TuiMcpServer mcpServer;
+ private TuiWebServer webServer;
private McpFacade mcpFacade;
private final Queue<McpFacade.PendingKey> pendingKeys = new
ConcurrentLinkedQueue<>();
private final CaptionOverlay captionOverlay = new CaptionOverlay();
@@ -141,6 +153,9 @@ public class CamelMonitor extends CamelCommand {
private ActionsPopup actionsPopup;
private TuiRunner runner;
+ // Set by TuiWebServer for browser sessions; local terminal sessions leave
this null
+ // and let TuiBackendHelper auto-detect the active terminal instead.
+ Backend webBackend;
private MonitorContext ctx;
@@ -514,7 +529,7 @@ public class CamelMonitor extends CamelCommand {
actionsPopup.setMcpEnabled(true, mcpPort,
mcpServer::getConnectedClient,
mcpServer::getActivityLog,
mcpServer::getToolCallCount);
mcpJsonFile = writeMcpJson(mcpPort);
- } catch (java.net.BindException e) {
+ } catch (BindException e) {
System.err.println("MCP server failed to start: port " +
mcpPort + " is already in use.");
System.err.println("Use --mcp-port to specify a different
port, e.g.: camel tui --mcp --mcp-port 8124");
mcpServer = null;
@@ -523,7 +538,21 @@ public class CamelMonitor extends CamelCommand {
}
aiPanel.setMcpInfo(mcp, mcpPort);
- try (var tui = TuiBackendHelper.createTuiRunner()) {
+ if (web) {
+ webServer = new TuiWebServer(webPort, getMain(), classLoader,
name, refreshInterval, theme);
+ try {
+ webServer.start();
+ } catch (BindException e) {
+ System.err.println("Web server failed to start: port " +
webPort + " is already in use.");
+ System.err.println("Use --web-port to specify a different
port, e.g.: camel tui --web --web-port 8091");
+ webServer = null;
+ web = false;
+ }
+ }
+
+ try (var tui = webBackend != null
+ ? TuiBackendHelper.createTuiRunner(webBackend)
+ : TuiBackendHelper.createTuiRunner()) {
this.runner = tui;
aiPanel.setExitCallbackForTestingOrRuntime(tui::quit);
ctx.runner = tui;
@@ -539,9 +568,14 @@ public class CamelMonitor extends CamelCommand {
applyLogPin();
applyRatePer();
applyConfirmActions();
- // Intercept Ctrl+C: quit the TUI cleanly instead of letting
- // the JVM tear down the classloader while we're still running
- Signal.handle(new Signal("INT"), sig -> tui.quit());
+ if (webBackend == null) {
+ // Intercept Ctrl+C: quit the TUI cleanly instead of letting
+ // the JVM tear down the classloader while we're still running.
+ // Signal.handle is process-wide and would clobber concurrent
sessions
+ // (e.g. browser connections via --web), so only the local
terminal
+ // session registers it.
+ Signal.handle(new Signal("INT"), sig -> tui.quit());
+ }
tui.run(
this::handleEvent,
this::render);
@@ -552,6 +586,9 @@ public class CamelMonitor extends CamelCommand {
if (mcpServer != null) {
mcpServer.stop();
}
+ if (webServer != null) {
+ webServer.stop();
+ }
deleteMcpJson(mcpJsonFile);
this.runner = null;
}
@@ -754,6 +791,8 @@ public class CamelMonitor extends CamelCommand {
boolean textEditing = probeEditing || sourceSearchActive ||
logSearchActive || spanFilterActive
|| beanFilterActive || classpathFilterActive ||
mavenDepsFilterActive || sqlInputActive
|| catalogFilterActive || filesBrowserTextActive;
+ // Each session (the local terminal, or a browser tab connected via
--web) owns an
+ // independent CamelMonitor/TuiRunner, so quitting here only ends this
session.
if (!textEditing && (ke.isCharIgnoreCase('q') || ke.isCtrlC())) {
if (!ke.isCtrlC() && ctx.confirmActions) {
popupManager.showConfirm("Confirm Quit", " Quit the TUI? ", ()
-> runner.quit());
@@ -1394,6 +1433,11 @@ public class CamelMonitor extends CamelCommand {
titleSpans.add(Span.raw(" "));
titleSpans.add(Span.styled(activeInfra + " infra(s)",
Theme.notice()));
}
+ if (web && webBackend == null) {
+ titleSpans.add(Span.raw(" "));
+ titleSpans.add(Span.styled("web :" + webPort,
+ Theme.notice().underlined().hyperlink("http://127.0.0.1:"
+ webPort + "/")));
+ }
if (ctx.selectedPid != null) {
titleSpans.add(Span.raw(" "));
InfraInfo selInfra = findSelectedInfra();
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java
index 748ff2980acd..d3abe4a7cda2 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java
@@ -17,6 +17,7 @@
package org.apache.camel.dsl.jbang.core.commands.tui;
import dev.tamboui.backend.jline3.JLineBackend;
+import dev.tamboui.terminal.Backend;
import dev.tamboui.tui.TuiConfig;
import dev.tamboui.tui.TuiRunner;
import org.apache.camel.dsl.jbang.core.common.EnvironmentHelper;
@@ -29,10 +30,15 @@ final class TuiBackendHelper {
static TuiRunner createTuiRunner() throws Exception {
Terminal activeTerminal = EnvironmentHelper.getActiveTerminal();
- if (activeTerminal != null) {
- JLineBackend backend = new JLineBackend(activeTerminal);
- return
TuiRunner.create(TuiConfig.builder().backend(backend).mouseCapture(true).build());
- }
- return
TuiRunner.create(TuiConfig.builder().mouseCapture(true).build());
+ // Build the JLine backend explicitly rather than leaving backend
selection to
+ // TamboUI's ServiceLoader-based auto-discovery: with
tamboui-aesh-backend also on the
+ // classpath (for --web), auto-discovery can pick AeshBackend for the
local session too,
+ // which drives a native PosixSysTerminal that doesn't shut down
cleanly here.
+ JLineBackend backend = activeTerminal != null ? new
JLineBackend(activeTerminal) : new JLineBackend();
+ return
TuiRunner.create(TuiConfig.builder().backend(backend).mouseCapture(true).build());
+ }
+
+ static TuiRunner createTuiRunner(Backend backend) throws Exception {
+ return
TuiRunner.create(TuiConfig.builder().backend(backend).mouseCapture(true).build());
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java
index 4b3081693e6d..b5e0b9fc3f80 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java
@@ -40,6 +40,15 @@ public class TuiCommand extends CamelCommand {
defaultValue = "8123")
int mcpPort = 8123;
+ @CommandLine.Option(names = { "--web" },
+ description = "Enable browser-accessible terminal
(WebSocket) server")
+ boolean web;
+
+ @CommandLine.Option(names = { "--web-port" },
+ description = "Web terminal server port (default:
${DEFAULT-VALUE})",
+ defaultValue = "8090")
+ int webPort = 8090;
+
@CommandLine.Option(names = { "--refresh" },
description = "Refresh interval in milliseconds
(default: ${DEFAULT-VALUE})",
defaultValue = "100")
@@ -73,6 +82,13 @@ public class TuiCommand extends CamelCommand {
args.add("--mcp-port");
args.add(String.valueOf(mcpPort));
}
+ if (web) {
+ args.add("--web");
+ }
+ if (webPort != 8090) {
+ args.add("--web-port");
+ args.add(String.valueOf(webPort));
+ }
if (refreshInterval != 100) {
args.add("--refresh");
args.add(String.valueOf(refreshInterval));
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServer.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServer.java
new file mode 100644
index 000000000000..55283961475f
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServer.java
@@ -0,0 +1,264 @@
+/*
+ * 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.io.IOException;
+import java.lang.System.Logger;
+import java.lang.System.Logger.Level;
+import java.net.BindException;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+import dev.tamboui.backend.aesh.AeshBackend;
+import io.netty.bootstrap.ServerBootstrap;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInitializer;
+import io.netty.channel.ChannelOutboundHandlerAdapter;
+import io.netty.channel.ChannelPipeline;
+import io.netty.channel.ChannelPromise;
+import io.netty.channel.EventLoopGroup;
+import io.netty.channel.MultiThreadIoEventLoopGroup;
+import io.netty.channel.SimpleChannelInboundHandler;
+import io.netty.channel.group.ChannelGroup;
+import io.netty.channel.group.DefaultChannelGroup;
+import io.netty.channel.nio.NioIoHandler;
+import io.netty.channel.socket.SocketChannel;
+import io.netty.channel.socket.nio.NioServerSocketChannel;
+import io.netty.handler.codec.http.DefaultFullHttpResponse;
+import io.netty.handler.codec.http.FullHttpRequest;
+import io.netty.handler.codec.http.HttpHeaderNames;
+import io.netty.handler.codec.http.HttpObjectAggregator;
+import io.netty.handler.codec.http.HttpResponse;
+import io.netty.handler.codec.http.HttpResponseStatus;
+import io.netty.handler.codec.http.HttpServerCodec;
+import io.netty.handler.codec.http.HttpVersion;
+import io.netty.handler.codec.http.QueryStringDecoder;
+import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
+import io.netty.handler.stream.ChunkedWriteHandler;
+import io.netty.util.concurrent.ImmediateEventExecutor;
+import org.aesh.terminal.Connection;
+import org.aesh.terminal.http.netty.HttpRequestHandler;
+import org.aesh.terminal.http.netty.TtyWebSocketFrameHandler;
+import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+
+/**
+ * Serves the Camel TUI dashboard to a web browser over WebSocket, using
Aesh's HTTP/WebSocket terminal bridge.
+ * <p>
+ * Each incoming connection gets its own {@link CamelMonitor} instance
(running the same live-monitoring logic as a
+ * local terminal session) driven by an {@link AeshBackend} wrapping that
connection.
+ * <p>
+ * Binds to 127.0.0.1 only for security.
+ */
+class TuiWebServer {
+
+ private static final Logger LOG =
System.getLogger(TuiWebServer.class.getName());
+ private static final int SESSION_QUEUE_CAPACITY = 64;
+ private static final long SHUTDOWN_TIMEOUT_SECONDS = 5;
+ private final int port;
+ private final CamelJBangMain main;
+ private final ClassLoader classLoader;
+ private final String name;
+ private final long refreshInterval;
+ private final String theme;
+ private final ChannelGroup channels = new
DefaultChannelGroup(ImmediateEventExecutor.INSTANCE);
+ private final EventLoopGroup bossGroup = new
MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory());
+ private final EventLoopGroup workerGroup = new
MultiThreadIoEventLoopGroup(NioIoHandler.newFactory());
+ private final ExecutorService sessionExecutor = new ThreadPoolExecutor(
+ Math.max(4, Runtime.getRuntime().availableProcessors() * 2),
+ Math.max(4, Runtime.getRuntime().availableProcessors() * 2),
+ 0L, TimeUnit.MILLISECONDS,
+ new ArrayBlockingQueue<>(SESSION_QUEUE_CAPACITY), r -> {
+ Thread t = new Thread(r, "tui-web-session");
+ t.setDaemon(true);
+ return t;
+ });
+ private final Consumer<Connection> sessionHandler;
+ private Channel serverChannel;
+ private boolean stopped;
+
+ TuiWebServer(int port, CamelJBangMain main, ClassLoader classLoader,
String name, long refreshInterval,
+ String theme) {
+ this(port, main, classLoader, name, refreshInterval, theme, null);
+ }
+
+ /**
+ * Package-private, for tests: lets a lightweight session handler replace
the default one, which spawns a full
+ * {@link CamelMonitor}.
+ */
+ TuiWebServer(int port, CamelJBangMain main, ClassLoader classLoader,
String name, long refreshInterval,
+ String theme, Consumer<Connection> sessionHandler) {
+ this.port = port;
+ this.main = main;
+ this.classLoader = classLoader;
+ this.name = name;
+ this.refreshInterval = refreshInterval;
+ this.theme = theme;
+ this.sessionHandler = sessionHandler != null ? sessionHandler :
this::runSession;
+ }
+
+ void start() throws IOException {
+ try {
+ ServerBootstrap bootstrap = new ServerBootstrap();
+ serverChannel = bootstrap.group(bossGroup, workerGroup)
+ .channel(NioServerSocketChannel.class)
+ .childHandler(new WebServerInitializer())
+ .bind("127.0.0.1", port)
+ .sync()
+ .channel();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ stop();
+ throw new IOException("Interrupted while starting web terminal
server", e);
+ } catch (Exception e) {
+ stop();
+ Throwable cause = e.getCause();
+ if (cause instanceof BindException bindException) {
+ throw bindException;
+ }
+ if (e instanceof BindException bindException) {
+ throw bindException;
+ }
+ throw new IOException("Failed to start web terminal server", e);
+ }
+ }
+
+ synchronized void stop() {
+ if (stopped) {
+ return;
+ }
+ stopped = true;
+ if (serverChannel != null) {
+ serverChannel.close().syncUninterruptibly();
+ }
+ channels.close().syncUninterruptibly();
+ bossGroup.shutdownGracefully(0, SHUTDOWN_TIMEOUT_SECONDS,
TimeUnit.SECONDS).syncUninterruptibly();
+ workerGroup.shutdownGracefully(0, SHUTDOWN_TIMEOUT_SECONDS,
TimeUnit.SECONDS).syncUninterruptibly();
+ sessionExecutor.shutdown();
+ try {
+ if (!sessionExecutor.awaitTermination(SHUTDOWN_TIMEOUT_SECONDS,
TimeUnit.SECONDS)) {
+ sessionExecutor.shutdownNow();
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ sessionExecutor.shutdownNow();
+ }
+ }
+
+ boolean awaitTermination(long timeout, TimeUnit unit) throws
InterruptedException {
+ long deadline = System.nanoTime() + unit.toNanos(timeout);
+ boolean bossDone = bossGroup.terminationFuture().await(Math.max(0,
deadline - System.nanoTime()), TimeUnit.NANOSECONDS);
+ boolean workerDone
+ = workerGroup.terminationFuture().await(Math.max(0, deadline -
System.nanoTime()), TimeUnit.NANOSECONDS);
+ boolean sessionDone
+ = sessionExecutor.awaitTermination(Math.max(0, deadline -
System.nanoTime()), TimeUnit.NANOSECONDS);
+ return bossDone && workerDone && sessionDone;
+ }
+
+ private void accept(Connection connection) {
+ try {
+ sessionExecutor.submit(() -> {
+ try {
+ sessionHandler.accept(connection);
+ } finally {
+ closeQuietly(connection);
+ }
+ });
+ } catch (RejectedExecutionException e) {
+ LOG.log(Level.WARNING, "Web TUI session rejected: server is
stopping or too many concurrent sessions");
+ closeQuietly(connection);
+ }
+ }
+
+ private void runSession(Connection connection) {
+ try {
+ AeshBackend backend = new AeshBackend(connection);
+ CamelMonitor monitor = new CamelMonitor(main, classLoader);
+ monitor.name = name;
+ monitor.refreshInterval = refreshInterval;
+ monitor.theme = theme;
+ monitor.webBackend = backend;
+ monitor.call();
+ } catch (Exception e) {
+ LOG.log(Level.WARNING, "Web TUI session ended with an error", e);
+ }
+ }
+
+ private static void closeQuietly(Connection connection) {
+ try {
+ connection.close();
+ } catch (Exception ignored) {
+ // connection already closing
+ }
+ }
+
+ private final class WebServerInitializer extends
ChannelInitializer<SocketChannel> {
+
+ @Override
+ protected void initChannel(SocketChannel channel) {
+ ChannelPipeline pipeline = channel.pipeline();
+ pipeline.addLast(new HttpServerCodec());
+ pipeline.addLast(new ChunkedWriteHandler());
+ pipeline.addLast(new HttpObjectAggregator(65_536));
+ pipeline.addLast(new OriginCheckingUpgradeHandler());
+ pipeline.addLast(new SecurityHeadersHandler());
+ pipeline.addLast(new HttpRequestHandler("/ws", "/tui/web"));
+ pipeline.addLast(new WebSocketServerProtocolHandler("/ws"));
+ pipeline.addLast(new TtyWebSocketFrameHandler(channels,
TuiWebServer.this::accept));
+ }
+ }
+
+ private final class OriginCheckingUpgradeHandler extends
SimpleChannelInboundHandler<FullHttpRequest> {
+
+ @Override
+ protected void channelRead0(ChannelHandlerContext context,
FullHttpRequest request) {
+ String path = new QueryStringDecoder(request.uri()).path();
+ if ("/ws".equalsIgnoreCase(path) && !isAllowedOrigin(request)) {
+ context.writeAndFlush(new
DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.FORBIDDEN))
+ .addListener(future -> context.close());
+ return;
+ }
+ context.fireChannelRead(request.retain());
+ }
+ }
+
+ /**
+ * Denies framing of the served pages, mitigating clickjacking against the
loopback web TUI.
+ */
+ private static final class SecurityHeadersHandler extends
ChannelOutboundHandlerAdapter {
+
+ @Override
+ public void write(ChannelHandlerContext context, Object msg,
ChannelPromise promise) {
+ if (msg instanceof HttpResponse response) {
+ response.headers().set("X-Frame-Options", "DENY");
+ response.headers().set("Content-Security-Policy",
"frame-ancestors 'none'");
+ }
+ context.write(msg, promise);
+ }
+ }
+
+ private boolean isAllowedOrigin(FullHttpRequest request) {
+ String origin = request.headers().get(HttpHeaderNames.ORIGIN);
+ // A missing Origin header is allowed on purpose: non-browser clients
(curl, custom
+ // terminal clients) don't send one, and the loopback-only bind is the
actual boundary here.
+ return origin == null || origin.equals("http://127.0.0.1:" + port) ||
origin.equals("http://localhost:" + port);
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/images/camel-logo.png
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/images/camel-logo.png
new file mode 100644
index 000000000000..36701f7ef086
Binary files /dev/null and
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/images/camel-logo.png
differ
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/index.html
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/index.html
new file mode 100644
index 000000000000..5ffe7b3cfd4f
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/index.html
@@ -0,0 +1,288 @@
+<!--
+
+ 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.
+
+-->
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Camel TUI</title>
+ <link rel="stylesheet" href="/vendor/xterm.css" />
+ <style>
+ :root {
+ --camel-orange: #e97826;
+ --camel-navy: #303284;
+ --camel-white: #fff;
+ }
+ * { box-sizing: border-box; }
+ html, body {
+ margin: 0;
+ padding: 0;
+ height: 100vh;
+ overflow: hidden;
+ background: #1e1e1e;
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, sans-serif;
+ }
+ body {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .site-nav {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ gap: .75rem;
+ height: 52px;
+ padding: 0 1.25rem;
+ background: var(--camel-navy);
+ color: var(--camel-white);
+ border-bottom: 3px solid var(--camel-orange);
+ box-shadow: 0 2px 10px rgba(0, 0, 0, .35);
+ }
+ .logo-mark {
+ flex-shrink: 0;
+ width: 28px;
+ height: 28px;
+ object-fit: cover;
+ object-position: left center;
+ }
+ .brand-name { font-weight: 700; font-size: .92rem; }
+ .brand-sub { font-size: .68rem; opacity: .65; }
+ .nav-spacer { flex: 1; }
+
+ .connection-status {
+ flex-shrink: 0;
+ padding: 3px 10px;
+ border-radius: 99px;
+ font-size: .68rem;
+ font-weight: 700;
+ letter-spacing: .04em;
+ text-transform: uppercase;
+ white-space: nowrap;
+ }
+ .status-connecting { background: #4a3a1a; color: #fbbf24; }
+ .status-connected { background: #1a472a; color: #4ade80; }
+ .status-disconnected { background: #4a1a1a; color: #f87171; }
+
+ #terminal-wrapper {
+ position: relative;
+ flex: 1;
+ min-height: 0;
+ }
+ #terminal-container {
+ position: absolute;
+ inset: 0;
+ padding: 12px;
+ background: #000;
+ }
+
+ .disconnect-overlay {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, .82);
+ }
+ .disconnect-overlay[hidden] { display: none; }
+ .disconnect-box {
+ max-width: 26rem;
+ padding: 1.25rem 1.5rem;
+ border-radius: 8px;
+ border-left: 4px solid var(--camel-orange);
+ background: #232323;
+ color: var(--camel-white);
+ box-shadow: 0 10px 30px rgba(0, 0, 0, .5);
+ }
+ .disconnect-title {
+ font-weight: 700;
+ font-size: 1rem;
+ margin-bottom: .35rem;
+ }
+ .disconnect-detail {
+ font-size: .85rem;
+ opacity: .8;
+ }
+ </style>
+</head>
+<body>
+ <nav class="site-nav" aria-label="Site navigation">
+ <img class="logo-mark" src="/images/camel-logo.png" alt=""
aria-hidden="true">
+ <div>
+ <div class="brand-name">Apache Camel</div>
+ <div class="brand-sub">Camel TUI</div>
+ </div>
+ <div class="nav-spacer"></div>
+ <div id="status" class="connection-status
status-connecting">Connecting...</div>
+ </nav>
+ <div id="terminal-wrapper">
+ <div id="terminal-container"></div>
+ <div id="disconnect-overlay" class="disconnect-overlay" hidden>
+ <div class="disconnect-box">
+ <div class="disconnect-title">Connection lost</div>
+ <div class="disconnect-detail" id="disconnect-detail"></div>
+ </div>
+ </div>
+ </div>
+
+ <script src="/vendor/xterm.js"></script>
+ <script src="/vendor/xterm-addon-fit.js"></script>
+ <script>
+ (function () {
+ 'use strict';
+
+ var statusEl = document.getElementById('status');
+ var overlayEl = document.getElementById('disconnect-overlay');
+ var overlayDetailEl = document.getElementById('disconnect-detail');
+
+ function setStatus(status, message) {
+ statusEl.className = 'connection-status status-' + status;
+ statusEl.textContent = message;
+ }
+
+ function showDisconnectOverlay(message) {
+ overlayDetailEl.textContent = message;
+ overlayEl.hidden = false;
+ }
+
+ function hideDisconnectOverlay() {
+ overlayEl.hidden = true;
+ }
+
+ function connect() {
+ var wsProtocol = window.location.protocol === 'https:' ? 'wss:' :
'ws:';
+ var wsUrl = wsProtocol + '//' + window.location.host + '/ws';
+
+ setStatus('connecting', 'Connecting...');
+ hideDisconnectOverlay();
+
+ var socket = new WebSocket(wsUrl);
+ var term = null;
+ var fitAddon = null;
+ var resizeTimeout = null;
+ var resizeObserver = null;
+
+ socket.onopen = function () {
+ setStatus('connected', 'Connected');
+
+ term = new Terminal({
+ cursorBlink: true,
+ cursorStyle: 'block',
+ fontFamily: '"DejaVu Sans Mono", "Liberation Mono", "Courier New",
monospace',
+ fontSize: 14,
+ theme: {
+ background: '#000000',
+ foreground: '#f0f0f0',
+ cursor: '#f0f0f0',
+ cursorAccent: '#000000',
+ selectionBackground: 'rgba(255, 255, 255, 0.3)'
+ },
+ allowProposedApi: true
+ });
+
+ fitAddon = new FitAddon.FitAddon();
+ term.loadAddon(fitAddon);
+ term.open(document.getElementById('terminal-container'));
+
+ function sendInit() {
+ fitAddon.fit();
+ // Message shape ({action: 'init'|'read'|'resize', ...}) is
dictated by
+ // org.aesh:terminal-http's server-side HttpTtyConnection - it is
not ours to change.
+ socket.send(JSON.stringify({
+ action: 'init',
+ type: 'xterm-256color',
+ cols: term.cols,
+ rows: term.rows,
+ userAgent: navigator.userAgent
+ }));
+ }
+
+ // fitAddon.fit() right after open() can measure a stale/default
size before the
+ // browser has finished laying out the page - defer past one paint
so the server
+ // starts with the terminal's real dimensions instead of drifting
and needing a
+ // resize correction.
+ requestAnimationFrame(function () {
+ requestAnimationFrame(sendInit);
+ });
+
+ socket.onmessage = function (event) {
+ term.write(event.data);
+ };
+
+ term.onData(function (data) {
+ socket.send(JSON.stringify({ action: 'read', data: data }));
+ });
+
+ term.onResize(function (size) {
+ socket.send(JSON.stringify({ action: 'resize', cols: size.cols,
rows: size.rows }));
+ });
+
+ function handleResize() {
+ if (resizeTimeout) {
+ clearTimeout(resizeTimeout);
+ }
+ resizeTimeout = setTimeout(function () {
+ if (fitAddon && term) {
+ fitAddon.fit();
+ }
+ }, 100);
+ }
+
+ window.addEventListener('resize', handleResize);
+ if (window.ResizeObserver) {
+ resizeObserver = new ResizeObserver(handleResize);
+
resizeObserver.observe(document.getElementById('terminal-container'));
+ }
+
+ socket.onclose = function (event) {
+ setStatus('disconnected', 'Disconnected (code: ' + event.code +
')');
+ showDisconnectOverlay('The session ended (code ' + event.code +
'). Reload the page to reconnect.');
+ window.removeEventListener('resize', handleResize);
+ if (resizeObserver) {
+ resizeObserver.disconnect();
+ }
+ if (resizeTimeout) {
+ clearTimeout(resizeTimeout);
+ }
+ if (term) {
+ term.dispose();
+ }
+ };
+
+ socket.onerror = function (error) {
+ setStatus('disconnected', 'Connection error');
+ showDisconnectOverlay('Lost contact with the Camel TUI server.
Reload the page to reconnect.');
+ console.error('WebSocket error:', error);
+ };
+
+ term.focus();
+ };
+
+ socket.onerror = function (error) {
+ setStatus('disconnected', 'Failed to connect');
+ showDisconnectOverlay('Could not reach the Camel TUI server. Reload
the page to retry.');
+ console.error('WebSocket connection error:', error);
+ };
+ }
+
+ window.addEventListener('load', connect);
+ })();
+ </script>
+</body>
+</html>
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/vendor/LICENSE-xterm.txt
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/vendor/LICENSE-xterm.txt
new file mode 100644
index 000000000000..234ea0e7c225
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/vendor/LICENSE-xterm.txt
@@ -0,0 +1,35 @@
+This directory bundles the following third-party components, vendored to
+avoid a runtime dependency on a CDN. Neither is modified from its upstream
+release.
+
+xterm.js 5.5.0 (xterm.js, xterm.css)
+https://github.com/xtermjs/xterm.js
+
+Copyright (c) 2017-2019, The xterm.js authors
(https://github.com/xtermjs/xterm.js)
+Copyright (c) 2014-2016, SourceLair Private Company
(https://www.sourcelair.com)
+Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/)
+
+xterm-addon-fit 0.10.0 (xterm-addon-fit.js)
+https://github.com/xtermjs/xterm.js/tree/master/addons/addon-fit
+
+Copyright (c) 2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)
+
+Both are released under the MIT License:
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/vendor/xterm-addon-fit.js
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/vendor/xterm-addon-fit.js
new file mode 100644
index 000000000000..038897193db5
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/vendor/xterm-addon-fit.js
@@ -0,0 +1,2 @@
+!function(e,t){"object"==typeof exports&&"object"==typeof
module?module.exports=t():"function"==typeof
define&&define.amd?define([],t):"object"==typeof
exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var
e={};return(()=>{var
t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void
0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const
e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const
t=this._ [...]
+//# sourceMappingURL=addon-fit.js.map
\ No newline at end of file
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/vendor/xterm.css
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/vendor/xterm.css
new file mode 100644
index 000000000000..b359fbfc9cce
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/vendor/xterm.css
@@ -0,0 +1,185 @@
+/**
+ * Default styles for xterm.js
+ */
+
+.xterm {
+ cursor: text;
+ position: relative;
+ user-select: none;
+ -ms-user-select: none;
+ -webkit-user-select: none;
+}
+
+.xterm.focus,
+.xterm:focus {
+ outline: none;
+}
+
+.xterm .xterm-helpers {
+ position: absolute;
+ top: 0;
+ /**
+ * The z-index of the helpers must be higher than the canvases in order for
+ * IMEs to appear on top.
+ */
+ z-index: 5;
+}
+
+.xterm .xterm-helper-textarea {
+ padding: 0;
+ border: 0;
+ margin: 0;
+ /* Move textarea out of the screen to the far left, so that the cursor is
not visible */
+ position: absolute;
+ opacity: 0;
+ left: -9999em;
+ top: 0;
+ width: 0;
+ height: 0;
+ z-index: -5;
+ /** Prevent wrapping so the IME appears against the textarea at the
correct position */
+ white-space: nowrap;
+ overflow: hidden;
+ resize: none;
+}
+
+.xterm .composition-view {
+ /* TODO: Composition position got messed up somewhere */
+ background: #000;
+ color: #FFF;
+ display: none;
+ position: absolute;
+ white-space: nowrap;
+ z-index: 1;
+}
+
+.xterm .composition-view.active {
+ display: block;
+}
+
+.xterm .xterm-viewport {
+ /* On OS X this is required in order for the scroll bar to appear fully
opaque */
+ background-color: #000;
+ overflow-y: scroll;
+ cursor: default;
+ position: absolute;
+ right: 0;
+ left: 0;
+ top: 0;
+ bottom: 0;
+}
+
+.xterm .xterm-screen {
+ position: relative;
+}
+
+.xterm .xterm-screen canvas {
+ position: absolute;
+ left: 0;
+ top: 0;
+}
+
+.xterm .xterm-scroll-area {
+ visibility: hidden;
+}
+
+.xterm-char-measure-element {
+ display: inline-block;
+ visibility: hidden;
+ position: absolute;
+ top: 0;
+ left: -9999em;
+ line-height: normal;
+}
+
+.xterm.enable-mouse-events {
+ /* When mouse events are enabled (eg. tmux), revert to the standard
pointer cursor */
+ cursor: default;
+}
+
+.xterm.xterm-cursor-pointer,
+.xterm .xterm-cursor-pointer {
+ cursor: pointer;
+}
+
+.xterm.column-select.focus {
+ /* Column selection mode */
+ cursor: crosshair;
+}
+
+.xterm .xterm-accessibility:not(.debug),
+.xterm .xterm-message {
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ right: 0;
+ z-index: 10;
+ color: transparent;
+ pointer-events: none;
+}
+
+.xterm .xterm-accessibility-tree:not(.debug) *::selection {
+ color: transparent;
+}
+
+.xterm .xterm-accessibility-tree {
+ user-select: text;
+ white-space: pre;
+}
+
+.xterm .live-region {
+ position: absolute;
+ left: -9999px;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+}
+
+.xterm-dim {
+ /* Dim should not apply to background, so the opacity of the foreground
color is applied
+ * explicitly in the generated class and reset to 1 here */
+ opacity: 1 !important;
+}
+
+.xterm-underline-1 { text-decoration: underline; }
+.xterm-underline-2 { text-decoration: double underline; }
+.xterm-underline-3 { text-decoration: wavy underline; }
+.xterm-underline-4 { text-decoration: dotted underline; }
+.xterm-underline-5 { text-decoration: dashed underline; }
+
+.xterm-overline {
+ text-decoration: overline;
+}
+
+.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
+.xterm-overline.xterm-underline-2 { text-decoration: overline double
underline; }
+.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
+.xterm-overline.xterm-underline-4 { text-decoration: overline dotted
underline; }
+.xterm-overline.xterm-underline-5 { text-decoration: overline dashed
underline; }
+
+.xterm-strikethrough {
+ text-decoration: line-through;
+}
+
+.xterm-screen .xterm-decoration-container .xterm-decoration {
+ z-index: 6;
+ position: absolute;
+}
+
+.xterm-screen .xterm-decoration-container
.xterm-decoration.xterm-decoration-top-layer {
+ z-index: 7;
+}
+
+.xterm-decoration-overview-ruler {
+ z-index: 8;
+ position: absolute;
+ top: 0;
+ right: 0;
+ pointer-events: none;
+}
+
+.xterm-decoration-top {
+ z-index: 2;
+ position: relative;
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/vendor/xterm.js
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/vendor/xterm.js
new file mode 100644
index 000000000000..7ca75f8e5ec2
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/web/vendor/xterm.js
@@ -0,0 +1,2 @@
+!function(e,t){if("object"==typeof exports&&"object"==typeof
module)module.exports=t();else if("function"==typeof
define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof
exports?exports:e)[s]=i[s]}}(globalThis,(()=>(()=>{"use strict";var
e={4567:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var
r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof
Reflect&&"function"==typeof Reflect.decorate)o=Reflect.d [...]
",e.GS="",e.RS="",e.US="",e.SP="
",e.DEL=""}(i||(t.C0=i={})),function(e){e.PAD="",e.HOP="",e.BPH="",e.NBH="",e.IND="",e.NEL="
",e.SSA="",e.ESA="",e.HTS="",e.HTJ="",e.VTS="",e.PLD="",e.PLU="",e.RI="",e.SS2="",e.SS3="",e.DCS="",e.PU1="",e.PU2="",e.STS="",e.CCH="",e.MW="",e.SPA="",e.EPA="",e.SOS="",e.SGCI="",e.SCI="",e.CSI="",e.ST="",e.OSC="",e.PM="",e.APC=""}(s||(t.C1=s={})),function(e){e.ST=`${i.ESC}\\`}(r||(t.C1_ESCAPED=r={}))},7399:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.evaluateKeyboardEvent=void
0;const s=i(2584),r={48:["0",")"],49:["1","!"],50:["2","@"],51:["3", [...]
+//# sourceMappingURL=xterm.js.map
\ No newline at end of file
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServerTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServerTest.java
new file mode 100644
index 000000000000..71e62f463ed4
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiWebServerTest.java
@@ -0,0 +1,235 @@
+/*
+ * 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.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.net.BindException;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.net.http.WebSocket;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.test.AvailablePortFinder;
+import org.apache.camel.test.AvailablePortFinder.Port;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Isolated;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@Isolated
+class TuiWebServerTest {
+
+ private TuiWebServer server;
+
+ @AfterEach
+ void tearDown() {
+ if (server != null) {
+ server.stop();
+ }
+ }
+
+ @Test
+ void startBindsToLoopbackAndAcceptsTcpConnections() throws Exception {
+ try (Port reserved = AvailablePortFinder.find()) {
+ server = newServer(reserved.getPort());
+
+ server.start();
+
+ try (Socket socket = new Socket()) {
+ socket.connect(new InetSocketAddress("127.0.0.1",
reserved.getPort()), 2000);
+ assertThat(socket.isConnected()).isTrue();
+ }
+ }
+ }
+
+ @Test
+ void stopClosesTheListeningPort() throws Exception {
+ try (Port reserved = AvailablePortFinder.find()) {
+ server = newServer(reserved.getPort());
+ server.start();
+
+ server.stop();
+
+ assertThatThrownBy(() -> {
+ try (Socket socket = new Socket()) {
+ socket.connect(new InetSocketAddress("127.0.0.1",
reserved.getPort()), 2000);
+ }
+ }).isInstanceOf(IOException.class);
+ }
+ }
+
+ @Test
+ void rejectsWebSocketUpgradeFromForeignOrigin() throws Exception {
+ try (Port reserved = AvailablePortFinder.find()) {
+ server = newServer(reserved.getPort());
+ server.start();
+
+ assertThat(webSocketHandshake(reserved.getPort(),
"https://attacker.invalid"))
+ .startsWith("HTTP/1.1 403");
+ }
+ }
+
+ @Test
+ void acceptsWebSocketUpgradeFromTheLoopbackPage() throws Exception {
+ try (Port reserved = AvailablePortFinder.find()) {
+ server = newServerWithNoOpSession(reserved.getPort());
+ server.start();
+
+ assertThat(webSocketHandshake(reserved.getPort(),
"http://127.0.0.1:" + reserved.getPort()))
+ .startsWith("HTTP/1.1 101");
+ }
+ }
+
+ @Test
+ void acceptsWebSocketUpgradeFromTheLoopbackHostname() throws Exception {
+ try (Port reserved = AvailablePortFinder.find()) {
+ server = newServerWithNoOpSession(reserved.getPort());
+ server.start();
+
+ assertThat(webSocketHandshake(reserved.getPort(),
"http://localhost:" + reserved.getPort()))
+ .startsWith("HTTP/1.1 101");
+ }
+ }
+
+ @Test
+ void startPropagatesBindExceptionForAnOccupiedPort() throws Exception {
+ try (Port reserved = AvailablePortFinder.find()) {
+ server = newServer(reserved.getPort());
+ server.start();
+ TuiWebServer conflictingServer = newServer(reserved.getPort());
+
+
assertThatThrownBy(conflictingServer::start).isInstanceOf(BindException.class);
+ conflictingServer.stop();
+ }
+ }
+
+ @Test
+ void stopTerminatesTheServerEventLoops() throws Exception {
+ try (Port reserved = AvailablePortFinder.find()) {
+ server = newServer(reserved.getPort());
+ server.start();
+
+ server.stop();
+
+ assertThat(server.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
+ }
+ }
+
+ @Test
+ void acceptsAWebSocketHandshakeOnTheWsEndpoint() throws Exception {
+ try (Port reserved = AvailablePortFinder.find()) {
+ server = newServerWithNoOpSession(reserved.getPort());
+ server.start();
+
+ HttpClient client = HttpClient.newHttpClient();
+ CompletableFuture<WebSocket> handshake =
client.newWebSocketBuilder()
+ .buildAsync(URI.create("ws://127.0.0.1:" +
reserved.getPort() + "/ws"), new WebSocket.Listener() {
+ });
+
+ WebSocket webSocket = handshake.get(5, TimeUnit.SECONDS);
+ assertThat(webSocket).isNotNull();
+ webSocket.abort();
+ }
+ }
+
+ @Test
+ void servesTheCustomIndexPageWithVendoredAssets() throws Exception {
+ try (Port reserved = AvailablePortFinder.find()) {
+ server = newServer(reserved.getPort());
+ server.start();
+
+ HttpClient client = HttpClient.newHttpClient();
+ String base = "http://127.0.0.1:" + reserved.getPort();
+
+ HttpResponse<String> index = client.send(
+ HttpRequest.newBuilder(URI.create(base + "/")).build(),
HttpResponse.BodyHandlers.ofString());
+ assertThat(index.statusCode()).isEqualTo(200);
+
assertThat(index.headers().firstValue("content-type")).hasValueSatisfying(v ->
assertThat(v).contains("text/html"));
+ assertThat(index.body()).contains("Apache
Camel").contains("/vendor/xterm.js");
+
+ HttpResponse<String> xtermJs = client.send(
+ HttpRequest.newBuilder(URI.create(base +
"/vendor/xterm.js")).build(),
+ HttpResponse.BodyHandlers.ofString());
+ assertThat(xtermJs.statusCode()).isEqualTo(200);
+ assertThat(xtermJs.headers().firstValue("content-type"))
+ .hasValueSatisfying(v ->
assertThat(v).contains("javascript"));
+
+ HttpResponse<String> xtermCss = client.send(
+ HttpRequest.newBuilder(URI.create(base +
"/vendor/xterm.css")).build(),
+ HttpResponse.BodyHandlers.ofString());
+ assertThat(xtermCss.statusCode()).isEqualTo(200);
+ assertThat(xtermCss.headers().firstValue("content-type"))
+ .hasValueSatisfying(v ->
assertThat(v).contains("text/css"));
+
+ HttpResponse<String> fitAddon = client.send(
+ HttpRequest.newBuilder(URI.create(base +
"/vendor/xterm-addon-fit.js")).build(),
+ HttpResponse.BodyHandlers.ofString());
+ assertThat(fitAddon.statusCode()).isEqualTo(200);
+
+ HttpResponse<byte[]> logo = client.send(
+ HttpRequest.newBuilder(URI.create(base +
"/images/camel-logo.png")).build(),
+ HttpResponse.BodyHandlers.ofByteArray());
+ assertThat(logo.statusCode()).isEqualTo(200);
+ assertThat(logo.body()).hasSizeGreaterThan(0);
+ }
+ }
+
+ private static TuiWebServer newServer(int port) {
+ return new TuiWebServer(
+ port, new CamelJBangMain(),
Thread.currentThread().getContextClassLoader(), null, 200,
+ "dark");
+ }
+
+ /**
+ * A server whose accepted sessions do nothing, so tests exercising the
HTTP/WebSocket transport don't pay the cost
+ * of spinning up a full {@link CamelMonitor}.
+ */
+ private static TuiWebServer newServerWithNoOpSession(int port) {
+ return new TuiWebServer(
+ port, new CamelJBangMain(),
Thread.currentThread().getContextClassLoader(), null, 200,
+ "dark", connection -> {
+ });
+ }
+
+ private static String webSocketHandshake(int port, String origin) throws
IOException {
+ try (Socket socket = new Socket("127.0.0.1", port);
+ PrintWriter writer = new PrintWriter(new
OutputStreamWriter(socket.getOutputStream()), true);
+ BufferedReader reader = new BufferedReader(new
InputStreamReader(socket.getInputStream()))) {
+ writer.print("GET /ws HTTP/1.1\r\n");
+ writer.print("Host: 127.0.0.1:" + port + "\r\n");
+ writer.print("Upgrade: websocket\r\n");
+ writer.print("Connection: Upgrade\r\n");
+ writer.print("Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n");
+ writer.print("Sec-WebSocket-Version: 13\r\n");
+ writer.print("Origin: " + origin + "\r\n\r\n");
+ writer.flush();
+ return reader.readLine();
+ }
+ }
+}
diff --git a/parent/pom.xml b/parent/pom.xml
index 2ea1f1dbc68f..c93c72caa8c9 100644
--- a/parent/pom.xml
+++ b/parent/pom.xml
@@ -61,6 +61,7 @@
<activemq-version>5.19.9</activemq-version>
<activemq6-version>6.3.0</activemq6-version>
<activemq-artemis-version>2.55.0</activemq-artemis-version>
+ <aesh-terminal-version>3.14.1</aesh-terminal-version>
<agroal-version>3.2.1</agroal-version>
<allegro-converter-version>0.3.0</allegro-converter-version>
<amazon-kinesis-client-version>3.5.1</amazon-kinesis-client-version>