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>

Reply via email to