You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@datafusion.apache.org by mg...@apache.org on 2026/06/02 17:05:57 UTC

(datafusion-ballista) branch main updated: feat: Add web rendering of the TUI application (#1742)

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

martin-g pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-ballista.git


The following commit(s) were added to refs/heads/main by this push:
     new dbaadf3c4 feat: Add web rendering of the TUI application (#1742)
dbaadf3c4 is described below

commit dbaadf3c407c48d1f47986e188ad868b70fb07f6
Author: Martin Grigorov <ma...@users.noreply.github.com>
AuthorDate: Tue Jun 2 20:05:51 2026 +0300

    feat: Add web rendering of the TUI application (#1742)
    
    * Initial version of the TUI app as a WASM32 web application
    
    Renders the initial page but the navigation and auto-refresh are disabled
    
    Made with AI
    
    * Allow CORS only from localhost
    
    * Enable more functionality for the Web TUI app
    
    * WIP more cleanup, figuring out an event bus for web
    
    Signed-off-by: Martin Tzvetanov Grigorov <mg...@apache.org>
    
    * Fix fetching the scheduler state in WebTUI
    
    Still WIP: there is an issue with RefCell<App> mutably borrowed
    
    * Handle key press listening with AsyncAction indirection
    
    The overcomes the problem with using mutable RefCell inside spawn_local()
    
    * Do not try to use DateTime::now() in WASM32
    
    Use 0/epoch as a fallback timestamp when parsing Prometheus samples in
    WASM32
    
    * Add ASFv2 license headers to Trunk.toml and index.html
    
    * Fix clippy issues
    
    * Use JavaScript Date.now() to get the current time for WebTUI
    
    * Go back to "cli+tui" as default features
    
    * Better feature gate for Config::new()
    
    * Protect from inflight processing of tick events
    
    * Better error handling for Terminal::restore()
    
    * Run clippy per package/crate to have better control on the enabled features
    
    * Do not scroll the page on ArrowDown/Up events
    
    * Use CanvasBackend instead of DomBackend
    
    This renders the TUI app in a <canvas> HTML element and it looks much
    more like the terminal one.
    
    * Use WebGl2Backend for even better rendering than CanvasBackend
    
    * Render the Ballista logo in Web TUI
    
    * Use light yellow + bold for the table's header names in Executors/Metrics
    
    This makes it consistent with all other tables
    
    * Simplify the config parsing for TUI/Web
    
    In WebTUI we use only the default config, without loading files and env
    vars
    
    * Revert changes in executors/mod.rs
    
    * Revert useless changes in jobs/mod.rs
    
    * Revert useless changes in metrics/mod.rs
    
    * Revert useless changes in main/mod.rs
    
    * Revert useless changes in ui/mod.rs
    
    * Imports cleanup - tui vs web
    
    * Cleanup the async data loading for key events in the web-tui build
    
    * Rename terminal module 'native' to 'tui'. For consistency
    
    * Fail the build if both "tui" and "web" features are enabled in the same time
    
    * Sort the dependencies for the WebTUI
    
    * Enable "build-binary" feature for ballista-core.
    
    Fixes a Clippy error:
    ```
    error[E0432]: unresolved import `ballista_core::object_store`
      --> examples/examples/custom-client.rs:19:20
       |
    19 | use ballista_core::object_store::state_with_s3_support;
       |                    ^^^^^^^^^^^^ could not find `object_store` in `ballista_core`
    ```
    
    Signed-off-by: Martin Tzvetanov Grigorov <mg...@apache.org>
    
    * Make it possible to register custom CORS allowed origins via env var
    
    * Minor simplification
    
    * Make it possible to allow more CORS methods. Allow all headers.
    
    * Use RAII for resetting the tick_in_flight flag
    
    * Prevent overflow for tick_ms setting
    
    * More cleanup
    
    * Quote the TRUNK_STAGING_DIR env var to support spaces in folder names
    
    * Add a CI workflow that builds the Web TUI app
    
    * Install wasm32-unknown-unknown target
    
    * Build WebTUI app on MacOS
    
    Github Actions is short for Ubuntu runners lately
    
    * Do not create tarball for the uploaded webtui app
    
    * Make it possible to load configuration from the query string
    
    E.g. http://localhost:8080/?ballista_scheduler_url=http://localhost:50050&ballista_tick_interval=3000&ballista_http_timeout=3000&ballista_job_stage_plan_tree=true
    
    * remove unused import
    
    * Improve the structures of the tui vs web modules
    
    Signed-off-by: Martin Tzvetanov Grigorov <mg...@apache.org>
    
    * Fix all Clippy issues in the web part of TUI
    
    Signed-off-by: Martin Tzvetanov Grigorov <mg...@apache.org>
    
    * Immediately show the initially loaded data
    
    Signed-off-by: Martin Tzvetanov Grigorov <mg...@apache.org>
    
    * Add more docs to Trunk.toml and wasm.rs
    
    ---------
    
    Signed-off-by: Martin Tzvetanov Grigorov <mg...@apache.org>
---
 .github/workflows/web-tui.yml                      |  82 +++
 Cargo.lock                                         | 684 +++++++++++----------
 ballista-cli/Cargo.toml                            |  67 +-
 ballista-cli/Trunk.toml                            |  42 ++
 ballista-cli/ballista-web-tui.html                 |  58 ++
 ballista-cli/src/command.rs                        |  14 +-
 ballista-cli/src/lib.rs                            |  11 +-
 ballista-cli/src/main.rs                           |   2 +-
 ballista-cli/src/tui/app.rs                        | 428 ++++++++++++-
 ballista-cli/src/tui/domain/jobs.rs                |   1 +
 ballista-cli/src/tui/domain/metrics.rs             |   3 +-
 ballista-cli/src/tui/error.rs                      |  16 +-
 ballista-cli/src/tui/event.rs                      | 151 +++--
 ballista-cli/src/tui/http_client.rs                |   8 +-
 ballista-cli/src/tui/infrastructure/config.rs      | 118 +++-
 ballista-cli/src/tui/mod.rs                        | 381 +++++++++---
 ballista-cli/src/tui/terminal.rs                   | 134 ++--
 ballista-cli/src/tui/ui/header/ballista-logo.svg   |  29 +
 ballista-cli/src/tui/ui/header/mod.rs              |  11 +-
 .../src/tui/ui/main/executors/executors_table.rs   |   5 +-
 ballista-cli/src/tui/ui/main/executors/mod.rs      |   6 +-
 ballista-cli/src/tui/ui/main/jobs/mod.rs           |  11 +-
 ballista-cli/src/tui/ui/main/metrics/mod.rs        |  23 +-
 ballista-cli/src/tui/ui/main/mod.rs                |  19 +-
 ballista-cli/src/tui/ui/mod.rs                     |  14 +-
 ballista-cli/src/wasm.rs                           |  88 +++
 ballista/scheduler/Cargo.toml                      |   1 +
 ballista/scheduler/src/api/routes.rs               |  30 +-
 ci/scripts/rust_clippy.sh                          |   9 +-
 examples/Cargo.toml                                |   2 +-
 30 files changed, 1867 insertions(+), 581 deletions(-)

diff --git a/.github/workflows/web-tui.yml b/.github/workflows/web-tui.yml
new file mode 100644
index 000000000..233ac3899
--- /dev/null
+++ b/.github/workflows/web-tui.yml
@@ -0,0 +1,82 @@
+# 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.
+
+name: Ballista Web TUI
+
+permissions:
+  contents: read
+
+concurrency:
+  group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }}
+  cancel-in-progress: true
+
+on:
+  push:
+    branches: [ "main" ]
+    paths:
+      - "ballista/**"
+      - "ballista-cli/**"
+      - "Cargo.toml"
+      - "Cargo.lock"
+      - "rust-toolchain.toml"
+      - ".github/workflows/web-tui.yml"
+      - ".github/actions/setup-builder/**"
+  pull_request:
+    paths:
+      - "ballista/**"
+      - "ballista-cli/**"
+      - "Cargo.toml"
+      - "Cargo.lock"
+      - "rust-toolchain.toml"
+      - ".github/workflows/web-tui.yml"
+      - ".github/actions/setup-builder/**"
+  workflow_dispatch:
+
+jobs:
+  web-tui:
+    name: Build Web TUI WASM32 application
+    runs-on: macos-latest
+    steps:
+      - uses: actions/checkout@v6.0.2
+        with:
+          fetch-depth: 1
+
+      - name: Setup Rust toolchain
+        uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+        with:
+          toolchain: "stable"
+          targets: "wasm32-unknown-unknown"
+
+      - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #2.9.1
+
+      - name: Install Trunk
+        uses: taiki-e/install-action@8f531eaecd1898bc3da7d104ad91bee98d1b97bd # v2.79.9
+        with:
+          tool: trunk@0.21.14
+
+      - name: Build
+        working-directory: ballista-cli
+        run: |
+          trunk build --cargo-profile release-nonlto --minify --locked \
+            --no-default-features --features web
+
+      - name: Upload WASM32 application
+        uses: actions/upload-artifact@v7
+        with:
+          name: ballista-web-tui
+          retention-days: 7
+          path: target/web-tui/
diff --git a/Cargo.lock b/Cargo.lock
index 9cb567fef..50bfcad29 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -112,7 +112,7 @@ version = "1.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
 dependencies = [
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -123,7 +123,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
 dependencies = [
  "anstyle",
  "once_cell_polyfill",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -170,6 +170,15 @@ dependencies = [
  "object",
 ]
 
+[[package]]
+name = "arc-swap"
+version = "1.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
+dependencies = [
+ "rustversion",
+]
+
 [[package]]
 name = "arraydeque"
 version = "0.5.1"
@@ -190,9 +199,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
 
 [[package]]
 name = "arrow"
-version = "58.1.0"
+version = "58.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d441fdda254b65f3e9025910eb2c2066b6295d9c8ed409522b8d2ace1ff8574c"
+checksum = "378530e55cd479eda3c14eb345310799717e6f76d0c332041e8487022166b471"
 dependencies = [
  "arrow-arith",
  "arrow-array",
@@ -211,9 +220,9 @@ dependencies = [
 
 [[package]]
 name = "arrow-arith"
-version = "58.1.0"
+version = "58.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ced5406f8b720cc0bc3aa9cf5758f93e8593cda5490677aa194e4b4b383f9a59"
+checksum = "a0ab212d2c1886e802f51c5212d78ebbcbb0bec980fff9dadc1eb8d45cd0b738"
 dependencies = [
  "arrow-array",
  "arrow-buffer",
@@ -225,9 +234,9 @@ dependencies = [
 
 [[package]]
 name = "arrow-array"
-version = "58.1.0"
+version = "58.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "772bd34cacdda8baec9418d80d23d0fb4d50ef0735685bd45158b83dfeb6e62d"
+checksum = "cfd33d3e92f207444098c75b42de99d329562be0cf686b307b097cc52b4e999e"
 dependencies = [
  "ahash",
  "arrow-buffer",
@@ -236,7 +245,7 @@ dependencies = [
  "chrono",
  "chrono-tz",
  "half",
- "hashbrown 0.16.1",
+ "hashbrown 0.17.1",
  "num-complex",
  "num-integer",
  "num-traits",
@@ -244,9 +253,9 @@ dependencies = [
 
 [[package]]
 name = "arrow-buffer"
-version = "58.1.0"
+version = "58.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "898f4cf1e9598fdb77f356fdf2134feedfd0ee8d5a4e0a5f573e7d0aec16baa4"
+checksum = "0c6cd424c2693bcdbc150d843dc9d4d137dd2de4782ce6df491ad11a3a0416c0"
 dependencies = [
  "bytes",
  "half",
@@ -256,9 +265,9 @@ dependencies = [
 
 [[package]]
 name = "arrow-cast"
-version = "58.1.0"
+version = "58.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b0127816c96533d20fc938729f48c52d3e48f99717e7a0b5ade77d742510736d"
+checksum = "4c5aefb56a2c02e9e2b30746241058b85f8983f0fcff2ba0c6d09006e1cded7f"
 dependencies = [
  "arrow-array",
  "arrow-buffer",
@@ -267,7 +276,7 @@ dependencies = [
  "arrow-schema",
  "arrow-select",
  "atoi",
- "base64 0.22.1",
+ "base64",
  "chrono",
  "comfy-table",
  "half",
@@ -278,9 +287,9 @@ dependencies = [
 
 [[package]]
 name = "arrow-csv"
-version = "58.1.0"
+version = "58.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ca025bd0f38eeecb57c2153c0123b960494138e6a957bbda10da2b25415209fe"
+checksum = "e94e8cf7e517657a52b91ea1263acf38c4ca62a84655d72458a3359b12ab97de"
 dependencies = [
  "arrow-array",
  "arrow-cast",
@@ -293,9 +302,9 @@ dependencies = [
 
 [[package]]
 name = "arrow-data"
-version = "58.1.0"
+version = "58.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42d10beeab2b1c3bb0b53a00f7c944a178b622173a5c7bcabc3cb45d90238df4"
+checksum = "3c88210023a2bfee1896af366309a3028fc3bcbd6515fa29a7990ee1baa08ee0"
 dependencies = [
  "arrow-buffer",
  "arrow-schema",
@@ -306,9 +315,9 @@ dependencies = [
 
 [[package]]
 name = "arrow-flight"
-version = "58.1.0"
+version = "58.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "302b2e036335f3f04d65dad3f74ff1f2aae6dc671d6aa04dc6b61193761e16fb"
+checksum = "28abfe8bf9f124e5fc83b334af4fa58f8d0323ad25312ccb2d1da50178415704"
 dependencies = [
  "arrow-arith",
  "arrow-array",
@@ -321,7 +330,7 @@ dependencies = [
  "arrow-schema",
  "arrow-select",
  "arrow-string",
- "base64 0.22.1",
+ "base64",
  "bytes",
  "futures",
  "once_cell",
@@ -334,9 +343,9 @@ dependencies = [
 
 [[package]]
 name = "arrow-ipc"
-version = "58.1.0"
+version = "58.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "609a441080e338147a84e8e6904b6da482cefb957c5cdc0f3398872f69a315d0"
+checksum = "238438f0834483703d88896db6fe5a7138b2230debc31b34c0336c2996e3c64f"
 dependencies = [
  "arrow-array",
  "arrow-buffer",
@@ -350,15 +359,16 @@ dependencies = [
 
 [[package]]
 name = "arrow-json"
-version = "58.1.0"
+version = "58.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6ead0914e4861a531be48fe05858265cf854a4880b9ed12618b1d08cba9bebc8"
+checksum = "205ca2119e6d679d5c133c6f30e68f027738d95ed948cf77677ea69c7800036b"
 dependencies = [
  "arrow-array",
  "arrow-buffer",
  "arrow-cast",
- "arrow-data",
+ "arrow-ord",
  "arrow-schema",
+ "arrow-select",
  "chrono",
  "half",
  "indexmap 2.14.0",
@@ -374,9 +384,9 @@ dependencies = [
 
 [[package]]
 name = "arrow-ord"
-version = "58.1.0"
+version = "58.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "763a7ba279b20b52dad300e68cfc37c17efa65e68623169076855b3a9e941ca5"
+checksum = "1bffd8fd2579286a5d63bac898159873e5094a79009940bcb42bbfce4f19f1d0"
 dependencies = [
  "arrow-array",
  "arrow-buffer",
@@ -387,9 +397,9 @@ dependencies = [
 
 [[package]]
 name = "arrow-row"
-version = "58.1.0"
+version = "58.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e14fe367802f16d7668163ff647830258e6e0aeea9a4d79aaedf273af3bdcd3e"
+checksum = "bab5994731204603c73ba69267616c50f80780774c6bb0476f1f830625115e0c"
 dependencies = [
  "arrow-array",
  "arrow-buffer",
@@ -400,9 +410,9 @@ dependencies = [
 
 [[package]]
 name = "arrow-schema"
-version = "58.1.0"
+version = "58.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c30a1365d7a7dc50cc847e54154e6af49e4c4b0fddc9f607b687f29212082743"
+checksum = "f633dbfdf39c039ada1bf9e34c694816eb71fbb7dc78f613993b7245e078a1ed"
 dependencies = [
  "serde_core",
  "serde_json",
@@ -410,9 +420,9 @@ dependencies = [
 
 [[package]]
 name = "arrow-select"
-version = "58.1.0"
+version = "58.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "78694888660a9e8ac949853db393af2a8b8fc82c19ce333132dfa2e72cc1a7fe"
+checksum = "8cd065c54172ac787cf3f2f8d4107e0d3fdc26edba76fdf4f4cc170258942222"
 dependencies = [
  "ahash",
  "arrow-array",
@@ -424,9 +434,9 @@ dependencies = [
 
 [[package]]
 name = "arrow-string"
-version = "58.1.0"
+version = "58.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61e04a01f8bb73ce54437514c5fd3ee2aa3e8abe4c777ee5cc55853b1652f79e"
+checksum = "29dd7cda3ab9692f43a2e4acc444d760cc17b12bb6d8232ddf64e9bab7c06b42"
 dependencies = [
  "arrow-array",
  "arrow-buffer",
@@ -457,9 +467,9 @@ dependencies = [
 
 [[package]]
 name = "async-compression"
-version = "0.4.41"
+version = "0.4.42"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1"
+checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
 dependencies = [
  "compression-codecs",
  "compression-core",
@@ -537,9 +547,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
 
 [[package]]
 name = "autocfg"
-version = "1.5.0"
+version = "1.5.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
 
 [[package]]
 name = "aws-config"
@@ -586,9 +596,9 @@ dependencies = [
 
 [[package]]
 name = "aws-lc-rs"
-version = "1.16.3"
+version = "1.17.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
+checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
 dependencies = [
  "aws-lc-sys",
  "zeroize",
@@ -596,9 +606,9 @@ dependencies = [
 
 [[package]]
 name = "aws-lc-sys"
-version = "0.40.0"
+version = "0.41.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7"
+checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
 dependencies = [
  "cc",
  "cmake",
@@ -633,9 +643,9 @@ dependencies = [
 
 [[package]]
 name = "aws-sdk-sso"
-version = "1.99.0"
+version = "1.100.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f4055e6099b2ec264abdc0d9bbfffce306c1601809275c861594779a0b04b45"
+checksum = "bee2719d4a5e5e147bb9e9b77490df6ece750df1094968aa857b09b618a1881a"
 dependencies = [
  "aws-credential-types",
  "aws-runtime",
@@ -657,10 +667,11 @@ dependencies = [
 
 [[package]]
 name = "aws-sdk-ssooidc"
-version = "1.101.0"
+version = "1.102.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02f009ba0284c5d696425fd7b4dcc5b189f5726f4041b7a5794daecb3a68d598"
+checksum = "b30d254992d56ef19f430396e5765b11e0f5bd21a7a557cb12fca1c8c18b9636"
 dependencies = [
+ "arc-swap",
  "aws-credential-types",
  "aws-runtime",
  "aws-smithy-async",
@@ -681,9 +692,9 @@ dependencies = [
 
 [[package]]
 name = "aws-sdk-sts"
-version = "1.104.0"
+version = "1.105.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6aa6622798e19e6a76b690562085dd4771c736cd48343464a53ab4ae2f2c9f84"
+checksum = "59f4f8065fe615dbed9096458ba98dda6d641553ffd5aedd27e37e65211aca9f"
 dependencies = [
  "aws-credential-types",
  "aws-runtime",
@@ -1026,16 +1037,20 @@ dependencies = [
  "chrono",
  "clap 4.6.1",
  "config",
+ "console_error_panic_hook",
  "crossterm",
  "datafusion",
  "datafusion-cli",
  "dirs",
  "dotparser",
  "futures",
+ "gloo-timers",
+ "js-sys",
  "mimalloc",
  "percent-encoding",
  "prometheus-parse",
  "ratatui",
+ "ratzilla",
  "reqwest 0.13.4",
  "rustyline",
  "serde",
@@ -1044,7 +1059,11 @@ dependencies = [
  "tracing",
  "tracing-appender",
  "tracing-subscriber",
+ "tracing-web",
  "tui-big-text",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
 ]
 
 [[package]]
@@ -1173,18 +1192,13 @@ dependencies = [
  "tonic",
  "tonic-prost",
  "tonic-prost-build",
+ "tower-http",
  "tracing",
  "tracing-appender",
  "tracing-subscriber",
  "uuid",
 ]
 
-[[package]]
-name = "base64"
-version = "0.21.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
-
 [[package]]
 name = "base64"
 version = "0.22.1"
@@ -1201,6 +1215,32 @@ dependencies = [
  "vsimd",
 ]
 
+[[package]]
+name = "beamterm-data"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bf74a7c20e83a40e542c032c918f1b5653ad4a137b49b7063e61030983ce243"
+dependencies = [
+ "compact_str",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "beamterm-renderer"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c27cc93582c7ce0f7350adcd2e0ffe21237687ba6bfb67cdea15e6e37001d19f"
+dependencies = [
+ "beamterm-data",
+ "compact_str",
+ "console_error_panic_hook",
+ "js-sys",
+ "thiserror 2.0.18",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
 [[package]]
 name = "bigdecimal"
 version = "0.4.10"
@@ -1242,6 +1282,18 @@ version = "2.11.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
 
+[[package]]
+name = "bitvec"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
+dependencies = [
+ "funty",
+ "radium",
+ "tap",
+ "wyz",
+]
+
 [[package]]
 name = "blake2"
 version = "0.10.6"
@@ -1253,9 +1305,9 @@ dependencies = [
 
 [[package]]
 name = "blake3"
-version = "1.8.4"
+version = "1.8.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e"
+checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
 dependencies = [
  "arrayref",
  "arrayvec",
@@ -1290,7 +1342,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4"
 dependencies = [
  "async-stream",
- "base64 0.22.1",
+ "base64",
  "bitflags 2.11.1",
  "bollard-buildkit-proto",
  "bollard-stubs",
@@ -1347,7 +1399,7 @@ version = "1.52.1-rc.29.1.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066"
 dependencies = [
- "base64 0.22.1",
+ "base64",
  "bollard-buildkit-proto",
  "bytes",
  "prost",
@@ -1384,9 +1436,9 @@ dependencies = [
 
 [[package]]
 name = "brotli"
-version = "8.0.2"
+version = "8.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
+checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
 dependencies = [
  "alloc-no-stdlib",
  "alloc-stdlib",
@@ -1395,19 +1447,28 @@ dependencies = [
 
 [[package]]
 name = "brotli-decompressor"
-version = "5.0.0"
+version = "5.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
+checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
 dependencies = [
  "alloc-no-stdlib",
  "alloc-stdlib",
 ]
 
+[[package]]
+name = "bs58"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
+dependencies = [
+ "tinyvec",
+]
+
 [[package]]
 name = "bumpalo"
-version = "3.20.2"
+version = "3.20.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
 
 [[package]]
 name = "bytemuck"
@@ -1469,9 +1530,9 @@ dependencies = [
 
 [[package]]
 name = "cc"
-version = "1.2.60"
+version = "1.2.63"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
+checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
 dependencies = [
  "find-msvc-tools",
  "jobserver",
@@ -1479,12 +1540,6 @@ dependencies = [
  "shlex",
 ]
 
-[[package]]
-name = "cesu8"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
-
 [[package]]
 name = "cfg-if"
 version = "1.0.4"
@@ -1628,9 +1683,9 @@ dependencies = [
 
 [[package]]
 name = "cmov"
-version = "0.5.3"
+version = "0.5.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
+checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
 
 [[package]]
 name = "colorchoice"
@@ -1660,9 +1715,9 @@ dependencies = [
 
 [[package]]
 name = "compact_str"
-version = "0.9.0"
+version = "0.9.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a"
+checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
 dependencies = [
  "castaway",
  "cfg-if",
@@ -1674,9 +1729,9 @@ dependencies = [
 
 [[package]]
 name = "compression-codecs"
-version = "0.4.37"
+version = "0.4.38"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7"
+checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
 dependencies = [
  "bzip2",
  "compression-core",
@@ -1689,9 +1744,9 @@ dependencies = [
 
 [[package]]
 name = "compression-core"
-version = "0.4.31"
+version = "0.4.32"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
+checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
 
 [[package]]
 name = "config"
@@ -1716,6 +1771,16 @@ dependencies = [
  "windows-sys 0.61.2",
 ]
 
+[[package]]
+name = "console_error_panic_hook"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "const-oid"
 version = "0.10.2"
@@ -1925,9 +1990,9 @@ dependencies = [
 
 [[package]]
 name = "crypto-common"
-version = "0.2.1"
+version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
 dependencies = [
  "hybrid-array",
 ]
@@ -2465,7 +2530,7 @@ checksum = "f28aa4e10384e782774b10e72aca4d93ef7b31aa653095d9d4536b0a3dbc51b6"
 dependencies = [
  "arrow",
  "arrow-buffer",
- "base64 0.22.1",
+ "base64",
  "blake2",
  "blake3",
  "chrono",
@@ -2949,13 +3014,13 @@ dependencies = [
 
 [[package]]
 name = "digest"
-version = "0.11.2"
+version = "0.11.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
 dependencies = [
  "block-buffer 0.12.0",
  "const-oid",
- "crypto-common 0.2.1",
+ "crypto-common 0.2.2",
  "ctutils",
 ]
 
@@ -2977,14 +3042,14 @@ dependencies = [
  "libc",
  "option-ext",
  "redox_users",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
 name = "displaydoc"
-version = "0.2.5"
+version = "0.2.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -2993,11 +3058,11 @@ dependencies = [
 
 [[package]]
 name = "docker_credential"
-version = "1.3.2"
+version = "1.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d89dfcba45b4afad7450a99b39e751590463e45c04728cf555d36bb66940de8"
+checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d"
 dependencies = [
- "base64 0.21.7",
+ "base64",
  "serde",
  "serde_json",
 ]
@@ -3051,9 +3116,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
 
 [[package]]
 name = "either"
-version = "1.15.0"
+version = "1.16.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
 
 [[package]]
 name = "encode_unicode"
@@ -3112,7 +3177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
 dependencies = [
  "libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -3191,13 +3256,12 @@ dependencies = [
 
 [[package]]
 name = "filetime"
-version = "0.2.27"
+version = "0.2.29"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
 dependencies = [
  "cfg-if",
  "libc",
- "libredox",
 ]
 
 [[package]]
@@ -3284,6 +3348,12 @@ version = "1.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
 
+[[package]]
+name = "funty"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
+
 [[package]]
 name = "futures"
 version = "0.3.32"
@@ -3357,9 +3427,9 @@ checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
 
 [[package]]
 name = "futures-timer"
-version = "3.0.3"
+version = "3.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
+checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
 
 [[package]]
 name = "futures-util"
@@ -3435,6 +3505,18 @@ version = "0.3.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
 
+[[package]]
+name = "gloo-timers"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "js-sys",
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "graphviz-rust"
 version = "0.9.8"
@@ -3453,9 +3535,9 @@ dependencies = [
 
 [[package]]
 name = "h2"
-version = "0.4.13"
+version = "0.4.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
+checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
 dependencies = [
  "atomic-waker",
  "bytes",
@@ -3516,9 +3598,9 @@ dependencies = [
 
 [[package]]
 name = "hashbrown"
-version = "0.17.0"
+version = "0.17.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
 
 [[package]]
 name = "hashlink"
@@ -3556,7 +3638,7 @@ version = "0.13.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
 dependencies = [
- "digest 0.11.2",
+ "digest 0.11.3",
 ]
 
 [[package]]
@@ -3643,18 +3725,18 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
 
 [[package]]
 name = "hybrid-array"
-version = "0.4.10"
+version = "0.4.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214"
+checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
 dependencies = [
  "typenum",
 ]
 
 [[package]]
 name = "hyper"
-version = "1.9.0"
+version = "1.10.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
 dependencies = [
  "atomic-waker",
  "bytes",
@@ -3722,7 +3804,7 @@ version = "0.1.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
 dependencies = [
- "base64 0.22.1",
+ "base64",
  "bytes",
  "futures-channel",
  "futures-util",
@@ -3887,9 +3969,9 @@ dependencies = [
 
 [[package]]
 name = "idna_adapter"
-version = "1.2.1"
+version = "1.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
 dependencies = [
  "icu_normalizer",
  "icu_properties",
@@ -3913,7 +3995,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
 dependencies = [
  "equivalent",
- "hashbrown 0.17.0",
+ "hashbrown 0.17.1",
  "serde",
  "serde_core",
 ]
@@ -3986,16 +4068,6 @@ version = "2.12.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
 
-[[package]]
-name = "iri-string"
-version = "0.7.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
-dependencies = [
- "memchr",
- "serde",
-]
-
 [[package]]
 name = "is_terminal_polyfill"
 version = "1.70.2"
@@ -4037,9 +4109,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
 
 [[package]]
 name = "jiff"
-version = "0.2.23"
+version = "0.2.28"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
+checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102"
 dependencies = [
  "jiff-static",
  "log",
@@ -4050,9 +4122,9 @@ dependencies = [
 
 [[package]]
 name = "jiff-static"
-version = "0.2.23"
+version = "0.2.28"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
+checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -4061,27 +4133,32 @@ dependencies = [
 
 [[package]]
 name = "jni"
-version = "0.21.1"
+version = "0.22.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
 dependencies = [
- "cesu8",
  "cfg-if",
  "combine",
- "jni-sys 0.3.1",
+ "jni-macros",
+ "jni-sys",
  "log",
- "thiserror 1.0.69",
+ "simd_cesu8",
+ "thiserror 2.0.18",
  "walkdir",
- "windows-sys 0.45.0",
+ "windows-link",
 ]
 
 [[package]]
-name = "jni-sys"
-version = "0.3.1"
+name = "jni-macros"
+version = "0.22.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
 dependencies = [
- "jni-sys 0.4.1",
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "simd_cesu8",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -4115,9 +4192,9 @@ dependencies = [
 
 [[package]]
 name = "js-sys"
-version = "0.3.95"
+version = "0.3.99"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
+checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
 dependencies = [
  "cfg-if",
  "futures-util",
@@ -4213,9 +4290,9 @@ dependencies = [
 
 [[package]]
 name = "libbz2-rs-sys"
-version = "0.2.3"
+version = "0.2.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f"
+checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
 
 [[package]]
 name = "libc"
@@ -4260,14 +4337,11 @@ dependencies = [
 
 [[package]]
 name = "libredox"
-version = "0.1.16"
+version = "0.1.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
+checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
 dependencies = [
- "bitflags 2.11.1",
  "libc",
- "plain",
- "redox_syscall 0.7.4",
 ]
 
 [[package]]
@@ -4281,9 +4355,9 @@ dependencies = [
 
 [[package]]
 name = "link-section"
-version = "0.18.0"
+version = "0.18.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a63f9687974de7e2caf6c96a1b19c6e882db299fdde09075c5b9e11c58421cd"
+checksum = "014e440054ce8170890229eeef5bcda955305e056ec713de40ed366944483f09"
 
 [[package]]
 name = "linktime-proc-macro"
@@ -4347,9 +4421,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
 
 [[package]]
 name = "lz4_flex"
-version = "0.13.0"
+version = "0.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db9a0d582c2874f68138a16ce1867e0ffde6c0bb0a0df85e1f36d04146db488a"
+checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e"
 dependencies = [
  "twox-hash",
 ]
@@ -4396,14 +4470,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
 dependencies = [
  "cfg-if",
- "digest 0.11.2",
+ "digest 0.11.3",
 ]
 
 [[package]]
 name = "memchr"
-version = "2.8.0"
+version = "2.8.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
 
 [[package]]
 name = "memmem"
@@ -4463,9 +4537,9 @@ dependencies = [
 
 [[package]]
 name = "mio"
-version = "1.2.0"
+version = "1.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
 dependencies = [
  "libc",
  "log",
@@ -4538,7 +4612,7 @@ version = "0.50.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
 dependencies = [
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -4577,9 +4651,9 @@ dependencies = [
 
 [[package]]
 name = "num-conv"
-version = "0.2.1"
+version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
 
 [[package]]
 name = "num-derive"
@@ -4677,7 +4751,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "622acbc9100d3c10e2ee15804b0caa40e55c933d5aa53814cd520805b7958a49"
 dependencies = [
  "async-trait",
- "base64 0.22.1",
+ "base64",
  "bytes",
  "chrono",
  "form_urlencoded",
@@ -4791,16 +4865,16 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
 dependencies = [
  "cfg-if",
  "libc",
- "redox_syscall 0.5.18",
+ "redox_syscall",
  "smallvec",
  "windows-link",
 ]
 
 [[package]]
 name = "parquet"
-version = "58.1.0"
+version = "58.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d3f9f2205199603564127932b89695f52b62322f541d0fc7179d57c2e1c9877"
+checksum = "5dafa7d01085b62a47dd0c1829550a0a36710ea9c4fe358a05a85477cec8a908"
 dependencies = [
  "ahash",
  "arrow-array",
@@ -4809,14 +4883,14 @@ dependencies = [
  "arrow-ipc",
  "arrow-schema",
  "arrow-select",
- "base64 0.22.1",
+ "base64",
  "brotli",
  "bytes",
  "chrono",
  "flate2",
  "futures",
  "half",
- "hashbrown 0.16.1",
+ "hashbrown 0.17.1",
  "lz4_flex",
  "num-bigint",
  "num-integer",
@@ -4876,7 +4950,7 @@ version = "0.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "898bac3fa00d0ba57a4e8289837e965baa2dee8c3749f3b11d45a64b4223d9c3"
 dependencies = [
- "base64 0.22.1",
+ "base64",
  "serde",
 ]
 
@@ -5040,18 +5114,18 @@ dependencies = [
 
 [[package]]
 name = "pin-project"
-version = "1.1.11"
+version = "1.1.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
 dependencies = [
  "pin-project-internal",
 ]
 
 [[package]]
 name = "pin-project-internal"
-version = "1.1.11"
+version = "1.1.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -5076,12 +5150,6 @@ version = "0.3.33"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
 
-[[package]]
-name = "plain"
-version = "0.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
-
 [[package]]
 name = "plotters"
 version = "0.3.7"
@@ -5327,9 +5395,9 @@ dependencies = [
 
 [[package]]
 name = "psm"
-version = "0.1.30"
+version = "0.1.31"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8"
+checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea"
 dependencies = [
  "ar_archive_writer",
  "cc",
@@ -5337,9 +5405,9 @@ dependencies = [
 
 [[package]]
 name = "pulldown-cmark"
-version = "0.13.3"
+version = "0.13.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad"
+checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
 dependencies = [
  "bitflags 2.11.1",
  "memchr",
@@ -5363,9 +5431,9 @@ checksum = "5a651516ddc9168ebd67b24afd085a718be02f8858fe406591b013d101ce2f40"
 
 [[package]]
 name = "quick-xml"
-version = "0.39.2"
+version = "0.39.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
+checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
 dependencies = [
  "memchr",
  "serde",
@@ -5448,6 +5516,12 @@ version = "6.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
 
+[[package]]
+name = "radium"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
+
 [[package]]
 name = "radix_trie"
 version = "0.2.1"
@@ -5604,6 +5678,22 @@ dependencies = [
  "unicode-width 0.2.2",
 ]
 
+[[package]]
+name = "ratzilla"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfc729362a3a8714b512f330c8dfaa03979813e9491e21b8e49b81e1e5d78014"
+dependencies = [
+ "beamterm-renderer",
+ "bitvec",
+ "compact_str",
+ "console_error_panic_hook",
+ "ratatui",
+ "thiserror 2.0.18",
+ "unicode-width 0.2.2",
+ "web-sys",
+]
+
 [[package]]
 name = "rayon"
 version = "1.12.0"
@@ -5653,15 +5743,6 @@ dependencies = [
  "bitflags 2.11.1",
 ]
 
-[[package]]
-name = "redox_syscall"
-version = "0.7.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
-dependencies = [
- "bitflags 2.11.1",
-]
-
 [[package]]
 name = "redox_users"
 version = "0.5.2"
@@ -5750,7 +5831,7 @@ version = "0.12.28"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
 dependencies = [
- "base64 0.22.1",
+ "base64",
  "bytes",
  "futures-core",
  "futures-util",
@@ -5792,7 +5873,7 @@ version = "0.13.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
 dependencies = [
- "base64 0.22.1",
+ "base64",
  "bytes",
  "encoding_rs",
  "futures-core",
@@ -5907,7 +5988,7 @@ dependencies = [
  "errno",
  "libc",
  "linux-raw-sys 0.12.1",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -5940,9 +6021,9 @@ dependencies = [
 
 [[package]]
 name = "rustls-pki-types"
-version = "1.14.0"
+version = "1.14.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
+checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
 dependencies = [
  "web-time",
  "zeroize",
@@ -5950,9 +6031,9 @@ dependencies = [
 
 [[package]]
 name = "rustls-platform-verifier"
-version = "0.6.2"
+version = "0.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
+checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
 dependencies = [
  "core-foundation 0.10.1",
  "core-foundation-sys",
@@ -5966,7 +6047,7 @@ dependencies = [
  "security-framework",
  "security-framework-sys",
  "webpki-root-certs",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -6244,11 +6325,12 @@ dependencies = [
 
 [[package]]
 name = "serde_with"
-version = "3.18.0"
+version = "3.20.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f"
+checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2"
 dependencies = [
- "base64 0.22.1",
+ "base64",
+ "bs58",
  "chrono",
  "hex",
  "indexmap 1.9.3",
@@ -6263,9 +6345,9 @@ dependencies = [
 
 [[package]]
 name = "serde_with_macros"
-version = "3.18.0"
+version = "3.20.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65"
+checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
 dependencies = [
  "darling 0.23.0",
  "proc-macro2",
@@ -6316,7 +6398,7 @@ checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
 dependencies = [
  "cfg-if",
  "cpufeatures 0.3.0",
- "digest 0.11.2",
+ "digest 0.11.3",
 ]
 
 [[package]]
@@ -6330,9 +6412,9 @@ dependencies = [
 
 [[package]]
 name = "shlex"
-version = "1.3.0"
+version = "2.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
 
 [[package]]
 name = "signal-hook"
@@ -6371,6 +6453,16 @@ version = "0.3.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
 
+[[package]]
+name = "simd_cesu8"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
+dependencies = [
+ "rustc_version",
+ "simdutf8",
+]
+
 [[package]]
 name = "simdutf8"
 version = "0.1.5"
@@ -6385,9 +6477,9 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
 
 [[package]]
 name = "siphasher"
-version = "1.0.2"
+version = "1.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
 
 [[package]]
 name = "slab"
@@ -6409,12 +6501,12 @@ checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b"
 
 [[package]]
 name = "socket2"
-version = "0.6.3"
+version = "0.6.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
+checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
 dependencies = [
  "libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -6447,15 +6539,15 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
 
 [[package]]
 name = "stacker"
-version = "0.1.23"
+version = "0.1.24"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013"
+checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190"
 dependencies = [
  "cc",
  "cfg-if",
  "libc",
  "psm",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -6652,6 +6744,12 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "tap"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
+
 [[package]]
 name = "tempfile"
 version = "3.27.0"
@@ -6662,7 +6760,7 @@ dependencies = [
  "getrandom 0.3.4",
  "once_cell",
  "rustix 1.1.4",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -6693,7 +6791,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
 dependencies = [
  "anyhow",
- "base64 0.22.1",
+ "base64",
  "bitflags 2.11.1",
  "fancy-regex",
  "filedescriptor",
@@ -6988,9 +7086,9 @@ dependencies = [
 
 [[package]]
 name = "toml_edit"
-version = "0.25.11+spec-1.1.0"
+version = "0.25.12+spec-1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
+checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
 dependencies = [
  "indexmap 2.14.0",
  "toml_datetime",
@@ -7015,7 +7113,7 @@ checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
 dependencies = [
  "async-trait",
  "axum",
- "base64 0.22.1",
+ "base64",
  "bytes",
  "h2",
  "http 1.4.1",
@@ -7097,20 +7195,20 @@ dependencies = [
 
 [[package]]
 name = "tower-http"
-version = "0.6.8"
+version = "0.6.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
 dependencies = [
  "bitflags 2.11.1",
  "bytes",
  "futures-util",
  "http 1.4.1",
  "http-body 1.0.1",
- "iri-string",
  "pin-project-lite",
  "tower",
  "tower-layer",
  "tower-service",
+ "url",
 ]
 
 [[package]]
@@ -7200,6 +7298,19 @@ dependencies = [
  "tracing-log",
 ]
 
+[[package]]
+name = "tracing-web"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e6a141feebd51f8d91ebfd785af50fca223c570b86852166caa3b141defe7c"
+dependencies = [
+ "js-sys",
+ "tracing-core",
+ "tracing-subscriber",
+ "wasm-bindgen",
+ "web-sys",
+]
+
 [[package]]
 name = "try-lock"
 version = "0.2.5"
@@ -7227,9 +7338,9 @@ checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
 
 [[package]]
 name = "typenum"
-version = "1.19.0"
+version = "1.20.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
 
 [[package]]
 name = "typify"
@@ -7349,7 +7460,7 @@ version = "3.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
 dependencies = [
- "base64 0.22.1",
+ "base64",
  "log",
  "percent-encoding",
  "rustls",
@@ -7364,7 +7475,7 @@ version = "0.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
 dependencies = [
- "base64 0.22.1",
+ "base64",
  "http 1.4.1",
  "httparse",
  "log",
@@ -7492,9 +7603,9 @@ dependencies = [
 
 [[package]]
 name = "wasm-bindgen"
-version = "0.2.118"
+version = "0.2.122"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
+checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
 dependencies = [
  "cfg-if",
  "once_cell",
@@ -7505,9 +7616,9 @@ dependencies = [
 
 [[package]]
 name = "wasm-bindgen-futures"
-version = "0.4.68"
+version = "0.4.72"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8"
+checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f"
 dependencies = [
  "js-sys",
  "wasm-bindgen",
@@ -7515,9 +7626,9 @@ dependencies = [
 
 [[package]]
 name = "wasm-bindgen-macro"
-version = "0.2.118"
+version = "0.2.122"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
+checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
 dependencies = [
  "quote",
  "wasm-bindgen-macro-support",
@@ -7525,9 +7636,9 @@ dependencies = [
 
 [[package]]
 name = "wasm-bindgen-macro-support"
-version = "0.2.118"
+version = "0.2.122"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
+checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
 dependencies = [
  "bumpalo",
  "proc-macro2",
@@ -7538,9 +7649,9 @@ dependencies = [
 
 [[package]]
 name = "wasm-bindgen-shared"
-version = "0.2.118"
+version = "0.2.122"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
+checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
 dependencies = [
  "unicode-ident",
 ]
@@ -7594,9 +7705,9 @@ dependencies = [
 
 [[package]]
 name = "web-sys"
-version = "0.3.95"
+version = "0.3.99"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d"
+checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
 dependencies = [
  "js-sys",
  "wasm-bindgen",
@@ -7715,7 +7826,7 @@ version = "0.1.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
 dependencies = [
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -7836,15 +7947,6 @@ dependencies = [
  "windows-link",
 ]
 
-[[package]]
-name = "windows-sys"
-version = "0.45.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
-dependencies = [
- "windows-targets 0.42.2",
-]
-
 [[package]]
 name = "windows-sys"
 version = "0.52.0"
@@ -7881,21 +7983,6 @@ dependencies = [
  "windows-link",
 ]
 
-[[package]]
-name = "windows-targets"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
-dependencies = [
- "windows_aarch64_gnullvm 0.42.2",
- "windows_aarch64_msvc 0.42.2",
- "windows_i686_gnu 0.42.2",
- "windows_i686_msvc 0.42.2",
- "windows_x86_64_gnu 0.42.2",
- "windows_x86_64_gnullvm 0.42.2",
- "windows_x86_64_msvc 0.42.2",
-]
-
 [[package]]
 name = "windows-targets"
 version = "0.52.6"
@@ -7938,12 +8025,6 @@ dependencies = [
  "windows-link",
 ]
 
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
-
 [[package]]
 name = "windows_aarch64_gnullvm"
 version = "0.52.6"
@@ -7956,12 +8037,6 @@ version = "0.53.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
 
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
-
 [[package]]
 name = "windows_aarch64_msvc"
 version = "0.52.6"
@@ -7974,12 +8049,6 @@ version = "0.53.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
 
-[[package]]
-name = "windows_i686_gnu"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
-
 [[package]]
 name = "windows_i686_gnu"
 version = "0.52.6"
@@ -8004,12 +8073,6 @@ version = "0.53.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
 
-[[package]]
-name = "windows_i686_msvc"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
-
 [[package]]
 name = "windows_i686_msvc"
 version = "0.52.6"
@@ -8022,12 +8085,6 @@ version = "0.53.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
 
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
-
 [[package]]
 name = "windows_x86_64_gnu"
 version = "0.52.6"
@@ -8040,12 +8097,6 @@ version = "0.53.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
 
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
-
 [[package]]
 name = "windows_x86_64_gnullvm"
 version = "0.52.6"
@@ -8058,12 +8109,6 @@ version = "0.53.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
 
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.42.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
-
 [[package]]
 name = "windows_x86_64_msvc"
 version = "0.52.6"
@@ -8078,9 +8123,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
 
 [[package]]
 name = "winnow"
-version = "1.0.1"
+version = "1.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
+checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
 dependencies = [
  "memchr",
 ]
@@ -8185,6 +8230,15 @@ version = "0.6.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
 
+[[package]]
+name = "wyz"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
+dependencies = [
+ "tap",
+]
+
 [[package]]
 name = "xattr"
 version = "1.6.1"
@@ -8237,18 +8291,18 @@ dependencies = [
 
 [[package]]
 name = "zerocopy"
-version = "0.8.48"
+version = "0.8.50"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
+checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
 dependencies = [
  "zerocopy-derive",
 ]
 
 [[package]]
 name = "zerocopy-derive"
-version = "0.8.48"
+version = "0.8.50"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
+checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -8257,9 +8311,9 @@ dependencies = [
 
 [[package]]
 name = "zerofrom"
-version = "0.1.7"
+version = "0.1.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
 dependencies = [
  "zerofrom-derive",
 ]
diff --git a/ballista-cli/Cargo.toml b/ballista-cli/Cargo.toml
index 6873e1b62..aa5dc1271 100644
--- a/ballista-cli/Cargo.toml
+++ b/ballista-cli/Cargo.toml
@@ -28,35 +28,80 @@ homepage = "https://datafusion.apache.org/ballista/"
 repository = "https://github.com/apache/datafusion-ballista"
 readme = "README.md"
 
+[lib]
+crate-type = ["cdylib", "rlib"]
+
 [dependencies]
-ballista = { path = "../ballista/client", version = "53.0.0", features = ["standalone"] }
+# CLI-only deps (DataFusion/Ballista) — gated by `cli` feature
+ballista = { path = "../ballista/client", version = "53.0.0", features = ["standalone"], optional = true }
+datafusion = { workspace = true, optional = true }
+datafusion-cli = { workspace = true, optional = true }
+rustyline = { version = "17.0.1", optional = true }
+mimalloc = { workspace = true, optional = true }
+
+# TUI/web shared deps
 chrono = { version = "0.4", default-features = false, features = ["std", "clock"], optional = true }
 clap = { workspace = true, features = ["derive", "cargo"] }
 config = { version = "0.15.23", default-features = false, features = ["yaml"], optional = true }
-crossterm = { version = "0.29.0", features = ["event-stream"], optional = true }
-datafusion = { workspace = true }
-datafusion-cli = { workspace = true }
 dirs = "6.0"
 dotparser = { version = "0.3.0", optional = true }
 futures = { version = "0.3.31", optional = true }
-mimalloc = { workspace = true }
 percent-encoding = { version = "2.3.2", optional = true }
 prometheus-parse = { version = "0.2", optional = true }
-ratatui = { version = "0.30.0", optional = true }
+ratatui = { version = "0.30.0", default-features = false, optional = true }
 reqwest = { version = "0.13.4", features = ["json"], optional = true }
-rustyline = "17.0.1"
 serde = { version = "1", features = ["derive"], optional = true }
 serde_json = { version = "1", optional = true }
-tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "sync", "time", "parking_lot"] }
 tracing = { version = "0.1" }
-tracing-appender = { version = "0.2", optional = true }
 tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
 tui-big-text = { version = "0.8.4", optional = true }
 
+# Native-only TUI deps
+crossterm = { version = "0.29.0", features = ["event-stream"], optional = true }
+tracing-appender = { version = "0.2", optional = true }
+
+# Web-only deps (WASM)
+console_error_panic_hook = { version = "0.1", optional = true }
+gloo-timers = { version = "0.4", features = ["futures"], optional = true }
+js-sys = { version = "0.3.95", optional = true }
+ratzilla = { version = "0.3", optional = true }
+tracing-web = { version = "0.1", optional = true }
+wasm-bindgen = { version = "0.2", optional = true }
+wasm-bindgen-futures = { version = "0.4", optional = true }
+web-sys = { version = "0.3", optional = true, default-features = false, features = ["Window", "Document", "Location"] }
+
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "sync", "time", "parking_lot"] }
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+tokio = { workspace = true, features = ["macros", "sync", "time"] }
+
 [features]
-default = ["tui"]
-tui = ["dep:chrono", "dep:config", "dep:crossterm", "dep:dotparser", "dep:futures", "dep:percent-encoding", "dep:prometheus-parse", "dep:ratatui", "dep:reqwest", "dep:serde", "dep:serde_json", "dep:tracing-appender", "dep:tui-big-text"]
+default = ["cli", "tui"]
+
+cli = [
+    "dep:ballista", "dep:datafusion", "dep:datafusion-cli",
+    "dep:rustyline", "dep:mimalloc",
+]
+
+tui = [
+    "dep:chrono", "dep:config", "dep:crossterm", "dep:dotparser", "dep:futures",
+    "dep:percent-encoding", "dep:prometheus-parse", "dep:ratatui", "dep:reqwest",
+    "dep:serde", "dep:serde_json", "dep:tracing-appender", "dep:tui-big-text",
+    "ratatui/crossterm", "ratatui/all-widgets", "ratatui/macros",
+    "ratatui/layout-cache", "ratatui/underline-color",
+]
+
+web = [
+    "dep:chrono", "dep:config", "dep:dotparser", "dep:futures", "dep:percent-encoding",
+    "dep:prometheus-parse", "dep:ratatui", "dep:reqwest",
+    "dep:serde", "dep:serde_json", "dep:tui-big-text",
+    "dep:ratzilla", "dep:wasm-bindgen", "dep:wasm-bindgen-futures",
+    "dep:gloo-timers", "dep:console_error_panic_hook",
+    "dep:tracing-web", "dep:web-sys", "dep:js-sys",
+]
 
 [[bin]]
 name = "ballista-cli"
 test = false
+required-features = ["cli"]
diff --git a/ballista-cli/Trunk.toml b/ballista-cli/Trunk.toml
new file mode 100644
index 000000000..5f5974148
--- /dev/null
+++ b/ballista-cli/Trunk.toml
@@ -0,0 +1,42 @@
+# 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.
+
+# Configuration file for Trunk - a Rust WASM builder & bundler for web applications
+# See all possible options and their meaning at https://github.com/trunk-rs/trunk/blob/main/Trunk.toml
+# Trunk is used to build ballista-cli's TUI application as a web application by using Ratzilla
+
+# Install trunk with: cargo binstall trunk
+# Build the web application with `dev` profile: 
+# 1) cd ballista-cli
+# 2) trunk serve --no-default-features --features web
+# 3) open http://localhost:8080
+
+[build]
+target = "ballista-web-tui.html"
+dist = "../target/web-tui"
+
+[watch]
+ignore = []
+
+[[hooks]]
+stage = "build"
+command = "sh"
+command_arguments = ["-c", "cp -f ./src/tui/ui/header/ballista-logo.svg \"$TRUNK_STAGING_DIR/ballista-logo.svg\""]
+
+[hooks.windows]
+command = "cmd"
+command_arguments = ["/c", "copy .\\src\\tui\\ui\\header\\ballista-logo.svg \"%TRUNK_STAGING_DIR%\\ballista-logo.svg\""]
\ No newline at end of file
diff --git a/ballista-cli/ballista-web-tui.html b/ballista-cli/ballista-web-tui.html
new file mode 100644
index 000000000..e55be1653
--- /dev/null
+++ b/ballista-cli/ballista-web-tui.html
@@ -0,0 +1,58 @@
+<!---
+  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">
+    <title>Ballista TUI</title>
+    <style>
+        body {
+            margin: 0;
+            background: #000;
+        }
+
+        #ballista-logo {
+            position: absolute;
+            top: 0;
+            left: 0;
+            width: 30%;
+            max-width: 500px;
+            max-height: 15vh;
+            object-fit: contain;
+            pointer-events: none;
+            z-index: 10;
+        }
+    </style>
+    <link data-trunk rel="copy-file" href="./src/tui/ui/header/ballista-logo.svg"/>
+</head>
+<body>
+<img src="ballista-logo.svg" id="ballista-logo" alt="Ballista Logo">
+
+<script type="module">
+    document.addEventListener('keydown', (event) => {
+        // prevent arrow keys from scrolling the page
+        // the arrow keys are handled by the application to select the next/previous table row
+        if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
+            event.preventDefault();
+        }
+    });
+</script>
+</body>
+</html>
diff --git a/ballista-cli/src/command.rs b/ballista-cli/src/command.rs
index 8ef3b414a..fcfdaeb32 100644
--- a/ballista-cli/src/command.rs
+++ b/ballista-cli/src/command.rs
@@ -20,7 +20,7 @@
 use std::str::FromStr;
 use std::sync::Arc;
 use std::sync::atomic::AtomicBool;
-#[cfg(feature = "tui")]
+#[cfg(all(not(feature = "web"), feature = "tui"))]
 use std::sync::atomic::Ordering;
 use std::time::Instant;
 
@@ -46,7 +46,7 @@ pub enum Command {
     SearchFunctions(String),
     QuietMode(Option<bool>),
     OutputFormat(Option<String>),
-    #[cfg(feature = "tui")]
+    #[cfg(all(not(feature = "web"), feature = "tui"))]
     OpenTui,
 }
 
@@ -135,7 +135,7 @@ impl Command {
                 "Unexpected change output format, this should be handled outside"
                     .to_string(),
             )),
-            #[cfg(feature = "tui")]
+            #[cfg(all(not(feature = "web"), feature = "tui"))]
             Self::OpenTui => {
                 /// RAII guard to reset tui mode back to false when exiting the TUI mode.
                 struct TuiModeGuard(Arc<AtomicBool>);
@@ -146,7 +146,7 @@ impl Command {
                 }
                 let _tui_mode_guard = TuiModeGuard(tui_mode.clone());
 
-                match crate::tui::tui_main(tui_mode.clone()).await {
+                match crate::tui::main(tui_mode.clone()).await {
                     Ok(()) => Ok(()),
                     Err(e) => Err(DataFusionError::Internal(format!(
                         "Error opening TUI: {e}",
@@ -168,7 +168,7 @@ impl Command {
             Self::OutputFormat(_) => {
                 ("\\pset [NAME [VALUE]]", "set table output option\n(format)")
             }
-            #[cfg(feature = "tui")]
+            #[cfg(all(not(feature = "web"), feature = "tui"))]
             Self::OpenTui => ("\\tui", "open tui"),
         }
     }
@@ -183,7 +183,7 @@ const ALL_COMMANDS: &[Command] = &[
     Command::SearchFunctions(String::new()),
     Command::QuietMode(None),
     Command::OutputFormat(None),
-    #[cfg(feature = "tui")]
+    #[cfg(all(not(feature = "web"), feature = "tui"))]
     Command::OpenTui,
 ];
 
@@ -233,7 +233,7 @@ impl FromStr for Command {
                 Self::OutputFormat(Some(subcommand.to_string()))
             }
             ("pset", None) => Self::OutputFormat(None),
-            #[cfg(feature = "tui")]
+            #[cfg(all(not(feature = "web"), feature = "tui"))]
             ("tui", None) => Self::OpenTui,
             _ => return Err(()),
         })
diff --git a/ballista-cli/src/lib.rs b/ballista-cli/src/lib.rs
index 01cd2331f..1103a8cba 100644
--- a/ballista-cli/src/lib.rs
+++ b/ballista-cli/src/lib.rs
@@ -16,11 +16,20 @@
 // under the License.
 
 #![doc = include_str!("../README.md")]
+
+#[cfg(all(feature = "tui", feature = "web"))]
+compile_error!("'tui' and 'web' features cannot be enabled at the same time!");
+
 pub const BALLISTA_CLI_VERSION: &str = env!("CARGO_PKG_VERSION");
 
+#[cfg(feature = "cli")]
 pub mod command;
+#[cfg(feature = "cli")]
 pub mod exec;
-#[cfg(feature = "tui")]
+#[cfg(any(feature = "tui", feature = "web"))]
 mod tui;
+#[cfg(feature = "web")]
+mod wasm;
 
+#[cfg(feature = "cli")]
 pub use datafusion_cli::{functions, helper, print_format, print_options};
diff --git a/ballista-cli/src/main.rs b/ballista-cli/src/main.rs
index 0eb033c25..ae2877581 100644
--- a/ballista-cli/src/main.rs
+++ b/ballista-cli/src/main.rs
@@ -118,7 +118,7 @@ pub async fn main() -> Result<(), Box<dyn std::error::Error>> {
 
     #[cfg(feature = "tui")]
     if args.tui {
-        return tui::tui_main(tui_mode.clone())
+        return tui::main(tui_mode.clone())
             .await
             .map_err(|e| Box::new(e) as Box<dyn std::error::Error>);
     }
diff --git a/ballista-cli/src/tui/app.rs b/ballista-cli/src/tui/app.rs
index 7bddf86b5..f8dad693c 100644
--- a/ballista-cli/src/tui/app.rs
+++ b/ballista-cli/src/tui/app.rs
@@ -15,9 +15,13 @@
 // specific language governing permissions and limitations
 // under the License.
 
+#[cfg(not(feature = "web"))]
+use crate::tui::TuiError;
 use crate::tui::TuiResult;
+use crate::tui::event::Event;
+#[cfg(feature = "web")]
+use crate::tui::event::web::Sender;
 use crate::tui::{
-    TuiError,
     domain::{
         SortOrder,
         executors::{
@@ -31,16 +35,21 @@ use crate::tui::{
         metrics::MetricsData,
         metrics::SortColumn as MetricsSortColumn,
     },
-    event::Event,
+    event::UiData,
     infrastructure::Settings,
 };
-use chrono::DateTime;
+use chrono::{DateTime, Utc};
+#[cfg(not(feature = "web"))]
 use crossterm::event::{KeyCode, KeyEvent};
+#[cfg(feature = "web")]
+use ratzilla::event::{KeyCode, KeyEvent};
 use std::string::ToString;
 use std::sync::Arc;
+#[cfg(not(feature = "web"))]
 use tokio::sync::mpsc::Sender;
 
 use crate::tui::http_client::HttpClient;
+#[cfg(not(feature = "web"))]
 use crate::tui::ui::{
     load_executor_details_popup, load_executors_data, load_job_details, load_job_dot,
     load_job_stages_popup, load_jobs_data, load_metrics_data,
@@ -133,6 +142,7 @@ impl App {
         self.input_mode == InputMode::Edit
     }
 
+    #[cfg(not(feature = "web"))]
     pub fn should_quit(&self) -> bool {
         self.should_quit
     }
@@ -141,6 +151,7 @@ impl App {
         self.event_tx = Some(tx);
     }
 
+    #[cfg(not(feature = "web"))]
     pub async fn send_event(&self, event: Event) -> TuiResult<()> {
         if let Some(tx) = &self.event_tx {
             tx.send(event)
@@ -153,6 +164,7 @@ impl App {
         }
     }
 
+    #[cfg(not(feature = "web"))]
     pub async fn on_tick(&mut self) {
         if self.is_executors_view() {
             self.load_executors_data().await;
@@ -174,6 +186,7 @@ impl App {
         }
     }
 
+    #[cfg(not(feature = "web"))]
     pub async fn on_key(&mut self, key: KeyEvent) -> TuiResult<()> {
         // Edit mode takes priority over everything
         if self.is_edit_mode() {
@@ -410,6 +423,7 @@ impl App {
         Ok(())
     }
 
+    #[cfg(not(feature = "web"))]
     async fn update_selected_job_details(&mut self) {
         self.job_details = None;
         if let Some(job_id) = self
@@ -421,12 +435,14 @@ impl App {
         }
     }
 
+    #[cfg(not(feature = "web"))]
     async fn load_selected_job_details(&self, job_id: &str) {
         if let Err(e) = load_job_details(self, job_id).await {
             tracing::error!("Failed to load job details: {e:?}");
         }
     }
 
+    #[cfg(not(feature = "web"))]
     async fn load_job_dot_data(&self) {
         if let Some(selected_job) = self.jobs_data.selected_job(&self.search_term) {
             if selected_job.status == "Completed"
@@ -439,6 +455,7 @@ impl App {
         }
     }
 
+    #[cfg(not(feature = "web"))]
     async fn load_job_stages_popup_data(&self) {
         if let Some(job) = self.jobs_data.selected_job(&self.search_term) {
             let job_id = job.job_id.clone();
@@ -448,6 +465,7 @@ impl App {
         }
     }
 
+    #[cfg(not(feature = "web"))]
     async fn load_executor_details_popup_data(&self) {
         if let Some(executor) = self.executors_data.selected_executor()
             && let Err(e) = load_executor_details_popup(self, &executor.id).await
@@ -459,18 +477,21 @@ impl App {
         }
     }
 
+    #[cfg(not(feature = "web"))]
     async fn load_executors_data(&mut self) {
         if let Err(e) = load_executors_data(self).await {
             tracing::error!("Failed to load executors data on tick: {e:?}");
         }
     }
 
+    #[cfg(not(feature = "web"))]
     async fn load_jobs_data(&mut self) {
         if let Err(e) = load_jobs_data(self).await {
             tracing::error!("Failed to load jobs data on tick: {e:?}");
         }
     }
 
+    #[cfg(not(feature = "web"))]
     async fn load_metrics_data(&mut self) {
         if let Err(e) = load_metrics_data(self).await {
             tracing::error!("Failed to load metrics data on tick: {e:?}");
@@ -527,20 +548,28 @@ impl App {
         self.metrics_data.sort();
     }
 
+    #[cfg(not(feature = "web"))]
     async fn cancel_selected_job(&mut self) {
         if let Some(job) = self.jobs_data.selected_job(&self.search_term)
             && (job.status == "Running" || job.status == "Queued")
         {
             let job_id = job.job_id.clone();
-            self.cancel_job_result =
-                Some(match self.http_client.cancel_job(&job_id).await {
-                    Ok(resp) if resp.canceled => CancelJobResult::Success { job_id },
-                    Ok(_) => CancelJobResult::NotCanceled { job_id },
-                    Err(e) => CancelJobResult::Failure {
-                        job_id,
-                        error: e.to_string(),
-                    },
-                });
+            let cancel_job_result = match self.http_client.cancel_job(&job_id).await {
+                Ok(resp) if resp.canceled => CancelJobResult::Success { job_id },
+                Ok(_) => CancelJobResult::NotCanceled { job_id },
+                Err(e) => CancelJobResult::Failure {
+                    job_id,
+                    error: e.to_string(),
+                },
+            };
+            if let Err(err) = self
+                .send_event(Event::DataLoaded {
+                    data: UiData::CancelJobResult(cancel_job_result),
+                })
+                .await
+            {
+                tracing::error!("Failed to send CancelJobResult event: {err:?}")
+            };
         }
     }
 
@@ -688,12 +717,378 @@ impl App {
             format!("{value:.2}{unit}")
         }
     }
+
+    #[cfg(not(feature = "web"))]
+    pub(crate) fn now() -> DateTime<Utc> {
+        Utc::now()
+    }
+
+    #[cfg(feature = "web")]
+    pub(crate) fn now() -> DateTime<Utc> {
+        let timestamp = js_sys::Date::now();
+        DateTime::from_timestamp_millis(timestamp as i64).unwrap_or_else(|| {
+            tracing::warn!("Failed to convert JS timestamp to DateTime. Falling back to Unix epoch for metrics' timestamps.");
+            DateTime::from_timestamp_millis(0).unwrap()
+        })
+    }
+
+    /// Applies a loaded data payload to the app state directly.
+    /// Used by the WASM path where data is loaded without going through the event channel.
+    pub fn apply_ui_data(&mut self, data: UiData) {
+        use ratatui::widgets::ScrollbarState;
+        match data {
+            UiData::SchedulerState(state) => {
+                self.executors_data.scheduler_state = state;
+            }
+            UiData::Executors(state, executors, jobs) => {
+                let old_pos = self.executors_data.scrollbar_state.get_position();
+                let scrollbar_state =
+                    ScrollbarState::new(executors.len()).position(old_pos);
+                let table_state = self.executors_data.table_state;
+                let sort_column = self.executors_data.sort_column.clone();
+                let sort_order = self.executors_data.sort_order.clone();
+                self.executors_data = ExecutorsData {
+                    executors,
+                    scrollbar_state,
+                    table_state,
+                    sort_column,
+                    sort_order,
+                    scheduler_state: state,
+                    jobs,
+                };
+                self.executors_data.sort();
+            }
+            UiData::Metrics(metrics) => {
+                let old_pos = self.metrics_data.scrollbar_state.get_position();
+                let scrollbar_state =
+                    ScrollbarState::new(metrics.len()).position(old_pos);
+                let table_state = self.metrics_data.table_state;
+                let sort_column = self.metrics_data.sort_column.clone();
+                let sort_order = self.metrics_data.sort_order.clone();
+                self.metrics_data = MetricsData {
+                    metrics,
+                    scrollbar_state,
+                    table_state,
+                    sort_column,
+                    sort_order,
+                };
+                self.metrics_data.sort();
+            }
+            UiData::Jobs(jobs) => {
+                let old_pos = self.jobs_data.scrollbar_state.get_position();
+                let scrollbar_state = ScrollbarState::new(jobs.len()).position(old_pos);
+                let table_state = self.jobs_data.table_state;
+                let sort_column = self.jobs_data.sort_column.clone();
+                let sort_order = self.jobs_data.sort_order.clone();
+                self.jobs_data = JobsData {
+                    jobs,
+                    scrollbar_state,
+                    table_state,
+                    sort_column,
+                    sort_order,
+                };
+            }
+            UiData::JobDetails(details) => {
+                self.job_details = Some(details);
+            }
+            UiData::JobStagesGraph(graph) => {
+                self.job_dot_popup = Some(graph);
+            }
+            UiData::JobStagesData(job_id, stages) => {
+                self.job_stages_popup = Some(JobStagesPopup::new(job_id, stages));
+            }
+            UiData::ExecutorDetails(executor) => {
+                self.executor_details_popup = Some(ExecutorDetailsPopup::new(executor));
+            }
+            UiData::CancelJobResult(result) => {
+                self.cancel_job_result = Some(result);
+            }
+        }
+    }
+
+    /// Synchronous portion of key handling for the WASM path. Returns an async action
+    /// descriptor when further async work (e.g. HTTP calls) is needed.
+    #[cfg(feature = "web")]
+    pub fn on_key_sync(&mut self, key: &KeyEvent) -> Option<WebKeyAsyncAction> {
+        if self.is_edit_mode() {
+            match key.code {
+                KeyCode::Esc => {
+                    self.search_term.clear();
+                    self.input_mode = InputMode::View;
+                }
+                KeyCode::Backspace => {
+                    self.search_term.pop();
+                }
+                KeyCode::Char(c) => {
+                    self.search_term.push(c);
+                }
+                _ => {}
+            }
+            return None;
+        }
+
+        if self.cancel_job_result.is_some() {
+            self.cancel_job_result = None;
+            return None;
+        }
+
+        if let Some(popup) = &mut self.job_stages_popup {
+            if popup.is_tasks_view() {
+                match key.code {
+                    KeyCode::Esc => popup.set_no_details_view(),
+                    KeyCode::Up => popup.scroll_up(),
+                    KeyCode::Down => popup.scroll_down(),
+                    _ => {}
+                }
+            } else if popup.is_plan_view() {
+                match key.code {
+                    KeyCode::Esc => popup.set_no_details_view(),
+                    KeyCode::Up => popup.scroll_up(),
+                    KeyCode::Down => popup.scroll_down(),
+                    KeyCode::Left => popup.scroll_left(),
+                    KeyCode::Right => popup.scroll_right(),
+                    _ => {}
+                }
+            } else if popup.is_no_details_view() {
+                match key.code {
+                    KeyCode::Up => popup.scroll_up(),
+                    KeyCode::Down => popup.scroll_down(),
+                    KeyCode::Enter if popup.selected_stage().is_some() => {
+                        popup.set_tasks_view();
+                    }
+                    KeyCode::Char('p') if popup.selected_stage().is_some() => {
+                        popup.set_plan_view();
+                    }
+                    KeyCode::Esc => {
+                        self.job_stages_popup = None;
+                    }
+                    _ => {}
+                }
+            }
+            return None;
+        }
+
+        if let Some(ref mut job_graph_popup) = self.job_dot_popup {
+            match key.code {
+                KeyCode::Up => job_graph_popup.scroll_up(),
+                KeyCode::Down => job_graph_popup.scroll_down(),
+                KeyCode::Esc => self.job_dot_popup = None,
+                _ => {}
+            }
+            return None;
+        }
+
+        if let Some(ref mut plans_popup) = self.job_plan_popup {
+            match key.code {
+                KeyCode::Up => plans_popup.scroll_up(),
+                KeyCode::Down => plans_popup.scroll_down(),
+                KeyCode::Left => plans_popup.scroll_left(),
+                KeyCode::Right => plans_popup.scroll_right(),
+                KeyCode::Char('s') => plans_popup.set_tab(PlanTab::Stage),
+                KeyCode::Char('p') => plans_popup.set_tab(PlanTab::Physical),
+                KeyCode::Char('l') => plans_popup.set_tab(PlanTab::Logical),
+                KeyCode::Esc => self.job_plan_popup = None,
+                _ => {}
+            }
+            return None;
+        }
+
+        if let Some(ref mut executor_popup) = self.executor_details_popup {
+            match key.code {
+                KeyCode::Up => executor_popup.scroll_up(),
+                KeyCode::Down => executor_popup.scroll_down(),
+                KeyCode::Esc => self.executor_details_popup = None,
+                _ => {}
+            }
+            return None;
+        }
+
+        if self.show_help || self.show_scheduler_info {
+            self.show_help = false;
+            self.show_scheduler_info = false;
+            return None;
+        }
+
+        match key.code {
+            KeyCode::Char('q') | KeyCode::Esc => {
+                self.should_quit = true;
+                None
+            }
+            KeyCode::Char('?') | KeyCode::Char('h') => {
+                self.show_help = true;
+                None
+            }
+            KeyCode::Char('i') => {
+                self.show_scheduler_info = true;
+                None
+            }
+            KeyCode::Enter if self.is_jobs_view() => {
+                let job_id = self
+                    .jobs_data
+                    .selected_job(&self.search_term)
+                    .map(|j| j.job_id.clone());
+                job_id.map(WebKeyAsyncAction::LoadJobStages)
+            }
+            KeyCode::Enter if self.is_executors_view() => {
+                let executor_id = self
+                    .executors_data
+                    .selected_executor()
+                    .map(|e| e.id.clone());
+                executor_id.map(WebKeyAsyncAction::LoadExecutorDetails)
+            }
+            KeyCode::Char('g') if self.is_jobs_view() => {
+                let job_id = self
+                    .jobs_data
+                    .selected_job(&self.search_term)
+                    .filter(|j| j.status == "Completed")
+                    .map(|j| j.job_id.clone());
+                job_id.map(WebKeyAsyncAction::LoadJobDot)
+            }
+            KeyCode::Char('p') if self.is_jobs_view() => {
+                self.open_job_plan_popup();
+                None
+            }
+            KeyCode::Char('e') if self.is_scheduler_up() => {
+                self.current_view = Views::Executors;
+                Some(WebKeyAsyncAction::ReloadView)
+            }
+            KeyCode::Char('j') if self.is_scheduler_up() => {
+                self.current_view = Views::Jobs;
+                Some(WebKeyAsyncAction::ReloadView)
+            }
+            KeyCode::Char('m') if self.is_scheduler_up() => {
+                self.current_view = Views::Metrics;
+                Some(WebKeyAsyncAction::ReloadView)
+            }
+            KeyCode::Char('/') if self.is_jobs_view() || self.is_metrics_view() => {
+                self.input_mode = InputMode::Edit;
+                None
+            }
+            KeyCode::Char('1') => {
+                if self.is_jobs_view() {
+                    self.sort_jobs_by(JobsSortColumn::Id);
+                } else if self.is_executors_view() {
+                    self.sort_executors_by(ExecutorsSortColumn::Host);
+                } else if self.is_metrics_view() {
+                    self.sort_metrics_by(MetricsSortColumn::Name);
+                }
+                None
+            }
+            KeyCode::Char('2') => {
+                if self.is_jobs_view() {
+                    self.sort_jobs_by(JobsSortColumn::Name);
+                } else if self.is_executors_view() {
+                    self.sort_executors_by(ExecutorsSortColumn::Id);
+                }
+                None
+            }
+            KeyCode::Char('3') => {
+                if self.is_jobs_view() {
+                    self.sort_jobs_by(JobsSortColumn::Status);
+                } else if self.is_executors_view() {
+                    self.sort_executors_by(ExecutorsSortColumn::CpuCores);
+                }
+                None
+            }
+            KeyCode::Char('4') => {
+                if self.is_jobs_view() {
+                    self.sort_jobs_by(JobsSortColumn::StagesCompleted);
+                } else if self.is_executors_view() {
+                    self.sort_executors_by(ExecutorsSortColumn::TaskSlots);
+                }
+                None
+            }
+            KeyCode::Char('5') => {
+                if self.is_jobs_view() {
+                    self.sort_jobs_by(JobsSortColumn::PercentComplete);
+                } else if self.is_executors_view() {
+                    self.sort_executors_by(ExecutorsSortColumn::ProcPhysicalMemoryUsage);
+                }
+                None
+            }
+            KeyCode::Char('6') => {
+                if self.is_jobs_view() {
+                    self.sort_jobs_by(JobsSortColumn::StartTime);
+                } else if self.is_executors_view() {
+                    self.sort_executors_by(ExecutorsSortColumn::PeakPhysicalMemoryUsage);
+                }
+                None
+            }
+            KeyCode::Char('7') => {
+                if self.is_jobs_view() {
+                    self.sort_jobs_by(JobsSortColumn::Duration);
+                } else if self.is_executors_view() {
+                    self.sort_executors_by(ExecutorsSortColumn::LastSeen);
+                }
+                None
+            }
+            KeyCode::Char('c')
+                if self.is_jobs_view() && self.input_mode == InputMode::View =>
+            {
+                let job_id = self
+                    .jobs_data
+                    .selected_job(&self.search_term)
+                    .filter(|j| j.status == "Running" || j.status == "Queued")
+                    .map(|j| j.job_id.clone());
+                job_id.map(WebKeyAsyncAction::CancelJob)
+            }
+            KeyCode::Down => {
+                if self.is_jobs_view() {
+                    self.jobs_data.scroll_down();
+                    let job_id = self
+                        .jobs_data
+                        .selected_job(&self.search_term)
+                        .map(|j| j.job_id.clone());
+                    self.job_details = None;
+                    Some(WebKeyAsyncAction::UpdateJobDetails(job_id))
+                } else if self.is_executors_view() {
+                    self.executors_data.scroll_down();
+                    None
+                } else if self.is_metrics_view() {
+                    self.metrics_data.scroll_down();
+                    None
+                } else {
+                    None
+                }
+            }
+            KeyCode::Up => {
+                if self.is_jobs_view() {
+                    self.jobs_data.scroll_up();
+                    let job_id = self
+                        .jobs_data
+                        .selected_job(&self.search_term)
+                        .map(|j| j.job_id.clone());
+                    self.job_details = None;
+                    Some(WebKeyAsyncAction::UpdateJobDetails(job_id))
+                } else if self.is_executors_view() {
+                    self.executors_data.scroll_up();
+                    None
+                } else if self.is_metrics_view() {
+                    self.metrics_data.scroll_up();
+                    None
+                } else {
+                    None
+                }
+            }
+            _ => None,
+        }
+    }
+}
+
+/// Async action descriptors returned by [`App::on_key_sync`] on the WASM path.
+#[cfg(feature = "web")]
+pub enum WebKeyAsyncAction {
+    LoadJobStages(String),
+    LoadExecutorDetails(String),
+    LoadJobDot(String),
+    CancelJob(String),
+    UpdateJobDetails(Option<String>),
+    ReloadView,
 }
 
 #[cfg(test)]
 mod tests {
-    use crate::tui::App;
-    use crate::tui::Settings;
+    use crate::tui::app::App;
     use crate::tui::app::{
         ExecutorsSortColumn, INVALID_DATE, JobsSortColumn, MetricsSortColumn,
     };
@@ -703,6 +1098,7 @@ mod tests {
         jobs::Job,
         jobs::stages::{JobStagesPopup, JobStagesResponse},
     };
+    use crate::tui::infrastructure::Settings;
 
     fn make_app() -> App {
         let settings =
@@ -753,6 +1149,7 @@ mod tests {
         assert!(!app.is_scheduler_up());
     }
 
+    #[cfg(not(feature = "web"))]
     #[test]
     fn new_app_should_not_quit() {
         let app = make_app();
@@ -1142,7 +1539,7 @@ mod tests {
 
     #[test]
     fn format_datetime_known_timestamp_contains_year() {
-        // 1_000_000_000_000 ms = 2001-09-09T01:46:40 UTC
+        // 1_000_000_000_000 ms = 2001-09-09T01:46:40
         let app = make_app();
         let result = app.format_datetime(1_000_000_000_000);
         assert!(result.contains("2001"), "{result} must contain year 2001");
@@ -1219,6 +1616,7 @@ mod tests {
     }
 
     #[test]
+    #[cfg(target_pointer_width = "64")]
     fn format_count_trillions() {
         let app = make_app();
         assert_eq!(app.format_count(1_000_000_000_000), "1.00T");
diff --git a/ballista-cli/src/tui/domain/jobs.rs b/ballista-cli/src/tui/domain/jobs.rs
index a057a4d71..df4e89cff 100644
--- a/ballista-cli/src/tui/domain/jobs.rs
+++ b/ballista-cli/src/tui/domain/jobs.rs
@@ -212,6 +212,7 @@ pub struct CancelJobResponse {
     pub canceled: bool,
 }
 
+#[derive(Clone, Debug)]
 pub enum CancelJobResult {
     Success { job_id: String },
     NotCanceled { job_id: String },
diff --git a/ballista-cli/src/tui/domain/metrics.rs b/ballista-cli/src/tui/domain/metrics.rs
index 07fadb952..1c9d86f36 100644
--- a/ballista-cli/src/tui/domain/metrics.rs
+++ b/ballista-cli/src/tui/domain/metrics.rs
@@ -15,6 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use crate::tui::app::App;
 use crate::tui::domain::SortOrder;
 use prometheus_parse::Sample;
 use ratatui::widgets::{ScrollbarState, TableState};
@@ -137,7 +138,7 @@ impl FromStr for MetricsResponse {
             .lines()
             .map(|line| Ok(line.to_string()))
             .collect();
-        let scrape = prometheus_parse::Scrape::parse(lines.into_iter())?;
+        let scrape = prometheus_parse::Scrape::parse_at(lines.into_iter(), App::now())?;
         for sample in scrape.samples {
             let metric = Metric {
                 sample: sample.clone(),
diff --git a/ballista-cli/src/tui/error.rs b/ballista-cli/src/tui/error.rs
index 127d14582..0f3e5c77c 100644
--- a/ballista-cli/src/tui/error.rs
+++ b/ballista-cli/src/tui/error.rs
@@ -15,9 +15,10 @@
 // specific language governing permissions and limitations
 // under the License.
 
+#[cfg(not(feature = "web"))]
 use crate::tui::event::Event;
 use config::ConfigError;
-use datafusion::common::DataFusionError;
+#[cfg(not(feature = "web"))]
 use tokio::sync::mpsc::error::SendError;
 use tracing_subscriber::filter::ParseError;
 
@@ -26,10 +27,10 @@ pub enum TuiError {
     Reqwest(reqwest::Error),
     Json(serde_json::Error),
     IO(std::io::Error),
+    #[cfg(not(feature = "web"))]
     SendError(Box<SendError<Event>>),
     Config(Box<ConfigError>),
     Tracing(Box<ParseError>),
-    DataFusion(Box<DataFusionError>),
 }
 
 impl std::fmt::Display for TuiError {
@@ -38,10 +39,10 @@ impl std::fmt::Display for TuiError {
             TuiError::Reqwest(err) => write!(f, "Reqwest error: {err}"),
             TuiError::Json(err) => write!(f, "JSON error: {err}"),
             TuiError::IO(err) => write!(f, "An IO error occurred: {err}"),
+            #[cfg(not(feature = "web"))]
             TuiError::SendError(err) => write!(f, "Send error: {err}"),
             TuiError::Config(err) => write!(f, "Config error: {err}"),
             TuiError::Tracing(err) => write!(f, "Tracing error: {err}"),
-            TuiError::DataFusion(err) => write!(f, "DataFusion error: {err}"),
         }
     }
 }
@@ -52,10 +53,10 @@ impl std::error::Error for TuiError {
             TuiError::Reqwest(err) => Some(err),
             TuiError::Json(err) => Some(err),
             TuiError::IO(err) => Some(err),
+            #[cfg(not(feature = "web"))]
             TuiError::SendError(err) => Some(err.as_ref()),
             TuiError::Config(err) => Some(err.as_ref()),
             TuiError::Tracing(err) => Some(err.as_ref()),
-            TuiError::DataFusion(err) => Some(err.as_ref()),
         }
     }
 }
@@ -72,6 +73,7 @@ impl From<serde_json::Error> for TuiError {
     }
 }
 
+#[cfg(not(feature = "web"))]
 impl From<SendError<Event>> for TuiError {
     fn from(err: SendError<Event>) -> Self {
         TuiError::SendError(Box::new(err))
@@ -95,9 +97,3 @@ impl From<ParseError> for TuiError {
         TuiError::Tracing(Box::new(err))
     }
 }
-
-impl From<DataFusionError> for TuiError {
-    fn from(err: DataFusionError) -> Self {
-        TuiError::DataFusion(Box::new(err))
-    }
-}
diff --git a/ballista-cli/src/tui/event.rs b/ballista-cli/src/tui/event.rs
index 1bd16925e..7d22ee0aa 100644
--- a/ballista-cli/src/tui/event.rs
+++ b/ballista-cli/src/tui/event.rs
@@ -15,22 +15,23 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use crossterm::event::{EventStream, KeyEvent};
-use futures::{FutureExt, StreamExt};
-use tokio::sync::mpsc;
-
 use crate::tui::domain::{
     SchedulerState,
     executors::Executor,
     jobs::{
-        Job, JobDetails,
+        CancelJobResult, Job, JobDetails,
         stages::{JobStagesResponse, StagesGraph},
     },
     metrics::Metric,
 };
+#[cfg(not(feature = "web"))]
+use crossterm::event::KeyEvent;
+#[cfg(feature = "web")]
+use ratzilla::event::KeyEvent;
 
 #[derive(Clone, Debug)]
 pub enum UiData {
+    SchedulerState(Option<SchedulerState>),
     Executors(Option<SchedulerState>, Vec<Executor>, Vec<Job>),
     Metrics(Vec<Metric>),
     Jobs(Vec<Job>),
@@ -38,6 +39,7 @@ pub enum UiData {
     JobStagesGraph(StagesGraph),
     JobStagesData(String, JobStagesResponse),
     ExecutorDetails(Executor),
+    CancelJobResult(CancelJobResult),
 }
 
 #[derive(Clone, Debug)]
@@ -48,45 +50,124 @@ pub enum Event {
     DataLoaded { data: UiData },
 }
 
-#[derive(Debug)]
-pub struct EventHandler {
-    rx: mpsc::UnboundedReceiver<Event>,
-}
+#[cfg(not(feature = "web"))]
+pub(crate) mod tui {
+    use crate::tui::event::Event;
+    use crossterm::event::EventStream;
+    use futures::{FutureExt, StreamExt};
+    use std::time::Duration;
+    use tokio::sync::mpsc;
+
+    #[derive(Debug)]
+    pub struct EventHandler {
+        rx: mpsc::UnboundedReceiver<Event>,
+    }
 
-impl EventHandler {
-    pub fn new(tick_rate: std::time::Duration) -> Self {
-        let (tx, rx) = mpsc::unbounded_channel();
+    impl EventHandler {
+        pub fn new(tick_rate: Duration) -> Self {
+            let (tx, rx) = mpsc::unbounded_channel();
 
-        tokio::spawn(async move {
-            let mut reader = EventStream::new();
-            let mut interval = tokio::time::interval(tick_rate);
+            tokio::spawn(async move {
+                let mut reader = EventStream::new();
+                let mut interval = tokio::time::interval(tick_rate);
 
-            loop {
-                let interval_delay = interval.tick();
-                let crossterm_event = reader.next().fuse();
+                loop {
+                    let interval_delay = interval.tick();
+                    let crossterm_event = reader.next().fuse();
 
-                tokio::select! {
-                    _ = interval_delay => {
-                        if tx.send(Event::Tick).is_err() {
-                            break;
+                    tokio::select! {
+                        _ = interval_delay => {
+                            if tx.send(Event::Tick).is_err() {
+                                break;
+                            }
+                        }
+                        Some(Ok(evt)) = crossterm_event => {
+                          if let crossterm::event::Event::Key(key) = evt
+                              && key.kind == crossterm::event::KeyEventKind::Press
+                                  && tx.send(Event::Key(key)).is_err()
+                              {
+                                  break;
+                              }
                         }
-                    }
-                    Some(Ok(evt)) = crossterm_event => {
-                      if let crossterm::event::Event::Key(key) = evt
-                          && key.kind == crossterm::event::KeyEventKind::Press
-                              && tx.send(Event::Key(key)).is_err()
-                          {
-                              break;
-                          }
                     }
                 }
-            }
-        });
+            });
+
+            Self { rx }
+        }
+
+        pub async fn next(&mut self) -> Option<Event> {
+            self.rx.recv().await
+        }
+    }
+}
+
+#[cfg(feature = "web")]
+pub(crate) mod web {
+    use crate::tui::TuiResult;
+    use crate::tui::event::Event;
+    use ratzilla::WebRenderer;
+    use std::cell::RefCell;
+    use std::collections::VecDeque;
+    use std::fmt::Debug;
+    use std::rc::Rc;
+
+    #[derive(Debug)]
+    pub struct EventHandler {
+        events: Rc<RefCell<VecDeque<Event>>>,
+    }
+
+    impl EventHandler {
+        /// Returns a `(Sender, EventHandler)` pair. The `Sender` can be cloned and distributed
+        /// to any code that needs to push events; the `EventHandler` drains the queue each frame.
+        pub fn new(
+            tick_rate: u32,
+            terminal: &ratatui::Terminal<ratzilla::WebGl2Backend>,
+        ) -> (Sender<Event>, Self) {
+            let queue = Rc::new(RefCell::new(VecDeque::new()));
+            let sender = Sender {
+                queue: Rc::clone(&queue),
+            };
+
+            tracing::info!("Setting up tick timer: refresh data every {tick_rate}ms");
+            let tick_sender = sender.clone();
+            gloo_timers::callback::Interval::new(tick_rate, move || {
+                tick_sender.queue.borrow_mut().push_back(Event::Tick);
+            })
+            .forget();
+
+            let key_sender = sender.clone();
+            terminal.on_key_event(move |key_event| {
+                tracing::debug!("on key event: {key_event:?}");
+                key_sender
+                    .queue
+                    .borrow_mut()
+                    .push_back(Event::Key(key_event));
+            });
+
+            (sender, Self { events: queue })
+        }
+
+        pub fn next(&mut self) -> Option<Event> {
+            self.events.borrow_mut().pop_front()
+        }
+    }
+
+    #[derive(Clone)]
+    pub struct Sender<E> {
+        queue: Rc<RefCell<VecDeque<E>>>,
+    }
 
-        Self { rx }
+    impl Debug for Sender<Event> {
+        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+            write!(f, "Sender")
+        }
     }
 
-    pub async fn next(&mut self) -> Option<Event> {
-        self.rx.recv().await
+    impl Sender<Event> {
+        pub async fn send(&self, event: Event) -> TuiResult<()> {
+            self.queue.borrow_mut().push_back(event);
+            Ok(())
+        }
     }
 }
diff --git a/ballista-cli/src/tui/http_client.rs b/ballista-cli/src/tui/http_client.rs
index 71c35fb08..e7320bf42 100644
--- a/ballista-cli/src/tui/http_client.rs
+++ b/ballista-cli/src/tui/http_client.rs
@@ -18,6 +18,7 @@
 use percent_encoding::{NON_ALPHANUMERIC, percent_encode};
 use reqwest::{Client, Response};
 use serde::de::DeserializeOwned;
+#[cfg(not(target_arch = "wasm32"))]
 use std::time::Duration;
 
 use crate::tui::{
@@ -39,10 +40,11 @@ pub struct HttpClient {
 
 impl HttpClient {
     pub fn new(config: Settings) -> TuiResult<Self> {
+        let builder = Client::builder();
+        #[cfg(not(target_arch = "wasm32"))]
+        let builder = builder.timeout(Duration::from_millis(config.http.timeout));
         Ok(Self {
-            client: Client::builder()
-                .timeout(Duration::from_millis(config.http.timeout))
-                .build()?,
+            client: builder.build()?,
             config,
         })
     }
diff --git a/ballista-cli/src/tui/infrastructure/config.rs b/ballista-cli/src/tui/infrastructure/config.rs
index 7cf001c85..c1c675f38 100644
--- a/ballista-cli/src/tui/infrastructure/config.rs
+++ b/ballista-cli/src/tui/infrastructure/config.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use config::{Config, ConfigError, Environment, File, FileFormat};
+use config::{Config, ConfigError, File, FileFormat};
 use serde::Deserialize;
 
 #[derive(Debug, Deserialize)]
@@ -23,6 +23,7 @@ pub struct SchedulerSettings {
     pub url: String,
 }
 
+#[cfg(not(target_arch = "wasm32"))]
 #[derive(Debug, Deserialize)]
 pub struct HttpSettings {
     /// Connection timeout. In millis
@@ -50,6 +51,7 @@ pub struct JobPlanSettings {
 #[derive(Debug, Deserialize)]
 pub struct Settings {
     pub scheduler: SchedulerSettings,
+    #[cfg(not(target_arch = "wasm32"))]
     pub http: HttpSettings,
     /// How often to refresh the UI. In millis.
     pub tick_interval_ms: u64,
@@ -74,26 +76,100 @@ job:
 
 impl Settings {
     pub(crate) fn new() -> Result<Self, ConfigError> {
-        let config_dir = dirs::config_local_dir()
-            .or_else(|| dirs::home_dir().map(|h| h.join(".config")))
-            .unwrap_or_else(|| std::path::PathBuf::from(".config"))
-            .join("ballista");
-
-        let s = Config::builder()
+        let builder = Config::builder()
             // Start off by merging in the "default" configuration file
-            .add_source(File::from_str(DEFAULT_CONFIG, FileFormat::Yaml))
-            // Add in user's config file
-            .add_source(
-                File::with_name(&format!("{}/tui", config_dir.display()))
-                    .format(FileFormat::Yaml)
-                    .required(false),
-            )
-            // Add in settings from the environment (with a prefix of BALLISTA_)
-            // E.g. `BALLISTA_SCHEDULER_URL=http://localhost:50051 ballista_cli`
-            // would set the scheduler url key
-            .add_source(Environment::with_prefix("BALLISTA").separator("_"))
-            .build()?;
-
-        s.try_deserialize()
+            .add_source(File::from_str(DEFAULT_CONFIG, FileFormat::Yaml));
+
+        #[cfg(all(feature = "web", not(feature = "tui")))]
+        let builder = builder.add_source(web::QueryString::parse());
+
+        #[cfg(all(feature = "tui", not(feature = "web")))]
+        let builder = {
+            use config::Environment;
+
+            let config_dir = dirs::config_local_dir()
+                .or_else(|| dirs::home_dir().map(|h| h.join(".config")))
+                .unwrap_or_else(|| std::path::PathBuf::from(".config"))
+                .join("ballista");
+
+            builder // Add in user's config file
+                .add_source(
+                    File::with_name(&format!("{}/tui", config_dir.display()))
+                        .format(FileFormat::Yaml)
+                        .required(false),
+                )
+                // Add in settings from the environment (with a prefix of BALLISTA_)
+                // E.g. `BALLISTA_SCHEDULER_URL=http://localhost:50051 ballista_cli`
+                // would set the scheduler url key
+                .add_source(Environment::with_prefix("BALLISTA").separator("_"))
+        };
+
+        let config = builder.build()?;
+
+        config.try_deserialize()
+    }
+}
+
+#[cfg(feature = "web")]
+mod web {
+    use config::{File, FileFormat, FileSourceString};
+
+    #[derive(Debug, Clone)]
+    pub(super) struct QueryString;
+
+    impl QueryString {
+        pub(super) fn parse() -> File<FileSourceString, FileFormat> {
+            let mut tick_interval_ms = 2000;
+            let mut scheduler_url = "http://localhost:50050";
+            let mut http_timeout_ms = 2000;
+            let mut format_tree = false;
+
+            let query_string = Self::decode_request();
+            query_string.split('&').for_each(|setting| {
+                let mut pair = setting.split('=');
+
+                if let (Some(key), Some(value)) = (pair.next(), pair.next()) {
+                    match key {
+                        "ballista_tick_interval" => {
+                            tick_interval_ms = value.parse::<u64>().unwrap_or(2000)
+                        }
+                        "ballista_scheduler_url" => scheduler_url = value,
+                        "ballista_http_timeout" => {
+                            http_timeout_ms = value.parse::<u64>().unwrap_or(2000)
+                        }
+                        "ballista_job_stage_plan_tree" => {
+                            format_tree = value.parse::<bool>().unwrap_or(false)
+                        }
+                        _ => {}
+                    }
+                }
+            });
+            let config = format!(
+                r#"
+tick_interval_ms: {tick_interval_ms}
+
+scheduler:
+  url: {scheduler_url}
+
+http:
+  timeout: {http_timeout_ms}
+
+job:
+  stage:
+    plan:
+      tree: {format_tree}
+"#
+            );
+            tracing::info!("Using query string: {}", config);
+            File::from_str(&config, FileFormat::Yaml)
+        }
+
+        fn decode_request() -> std::string::String {
+            let window = web_sys::window().expect("no global `window` exists");
+            let document = window.document().expect("no global document exist");
+            let location = document.location().expect("no location exists");
+            let raw_search = location.search().expect("no search exists");
+            raw_search.trim_start_matches("?").to_string()
+        }
     }
 }
diff --git a/ballista-cli/src/tui/mod.rs b/ballista-cli/src/tui/mod.rs
index eb05ef001..eb400e632 100644
--- a/ballista-cli/src/tui/mod.rs
+++ b/ballista-cli/src/tui/mod.rs
@@ -24,115 +24,318 @@ mod infrastructure;
 mod terminal;
 mod ui;
 
-use app::App;
-use event::{Event, EventHandler};
-use ratatui::widgets::ScrollbarState;
-use std::sync::Arc;
-use std::sync::atomic::{AtomicBool, Ordering};
-use std::time::Duration;
-use terminal::TuiWrapper;
-
-use crate::tui::domain::{
-    executors::{ExecutorDetailsPopup, ExecutorsData},
-    jobs::{JobsData, stages::JobStagesPopup},
-    metrics::MetricsData,
-};
-use crate::tui::{error::TuiError, event::UiData, infrastructure::Settings};
+use crate::tui::error::TuiError;
 
 pub type TuiResult<OK> = Result<OK, TuiError>;
 
-pub async fn tui_main(tui_mode: Arc<AtomicBool>) -> TuiResult<()> {
-    tui_mode.store(true, Ordering::Release);
-    tracing::info!("Starting the Ballista TUI application");
+#[cfg(not(feature = "web"))]
+pub(crate) use _tui::main;
+#[cfg(feature = "web")]
+pub(crate) use web::main;
 
-    let config = Settings::new()?;
-    tracing::debug!("TUI configuration: {:?}", config);
+#[cfg(not(feature = "web"))]
+mod _tui {
+    use crate::tui::{
+        TuiResult,
+        app::App,
+        event::{Event, UiData, tui::EventHandler},
+        infrastructure::Settings,
+        terminal::TuiWrapper,
+        ui,
+    };
+    use std::sync::{
+        Arc,
+        atomic::{AtomicBool, Ordering},
+    };
+    use std::time::Duration;
 
-    let mut tui_wrapper = TuiWrapper::new()?;
-    let mut events = EventHandler::new(Duration::from_millis(config.tick_interval_ms));
-    let mut app = App::new(config)?;
+    pub async fn main(tui_mode: Arc<AtomicBool>) -> TuiResult<()> {
+        tui_mode.store(true, Ordering::Release);
+        tracing::info!("Starting the Ballista TUI application");
 
-    let (app_tx, mut app_rx) = tokio::sync::mpsc::channel(16);
-    app.set_event_tx(app_tx);
-    let _ = ui::load_executors_data(&app).await;
+        let config = Settings::new()?;
+        tracing::debug!("TUI configuration: {:?}", config);
 
-    loop {
-        tui_wrapper.terminal.draw(|f| ui::render(f, &app))?;
+        let mut tui_wrapper = TuiWrapper::new()?;
+        let mut events =
+            EventHandler::new(Duration::from_millis(config.tick_interval_ms));
+        let mut app = App::new(config)?;
 
-        tokio::select! {
-            maybe_event = events.next() => {
-                match maybe_event {
-                    Some(Event::Key(key)) => app.on_key(key).await?,
-                    Some(Event::Tick) => app.on_tick().await,
-                    Some(evt) => tracing::debug!("Unexpected event: {evt:?}"),
-                    None => break,
-                }
+        let (app_tx, mut app_rx) = tokio::sync::mpsc::channel(16);
+        app.set_event_tx(app_tx);
+
+        let data = match app.http_client.get_scheduler_state().await {
+            Ok(state) => UiData::SchedulerState(Some(state)),
+            Err(e) => {
+                tracing::error!("Failed to load scheduler state: {e:?}");
+                UiData::SchedulerState(None)
             }
-            Some(app_event) = app_rx.recv() => {
-                if let Event::DataLoaded { data } = app_event {
-                  match data {
-                    UiData::Executors(state, executors, jobs) => {
-                            let old_scrollbar_position = app.executors_data.scrollbar_state.get_position();
-                            let scrollbar_state = ScrollbarState::new(executors.len()).position(old_scrollbar_position);
-                            app.executors_data = ExecutorsData {
-                                executors,
-                                scrollbar_state,
-                                table_state: app.executors_data.table_state,
-                                sort_column: app.executors_data.sort_column,
-                                sort_order: app.executors_data.sort_order,
-                                scheduler_state: state,
-                                jobs,
-                            };
-                            app.executors_data.sort();
-                    },
-                    UiData::Metrics(metrics) => {
-                            let old_scrollbar_position = app.metrics_data.scrollbar_state.get_position();
-                            let scrollbar_state = ScrollbarState::new(metrics.len()).position(old_scrollbar_position);
-                            app.metrics_data = MetricsData {
-                                metrics,
-                                scrollbar_state,
-                                table_state: app.metrics_data.table_state,
-                                sort_column: app.metrics_data.sort_column,
-                                sort_order: app.metrics_data.sort_order
-                            };
-                            app.metrics_data.sort();
+        };
+        app.send_event(Event::DataLoaded { data }).await.ok();
+
+        loop {
+            tui_wrapper.terminal.draw(|f| ui::render(f, &app))?;
+
+            tokio::select! {
+                maybe_event = events.next() => {
+                    match maybe_event {
+                        Some(Event::Key(key)) => app.on_key(key).await?,
+                        Some(Event::Tick) => app.on_tick().await,
+                        Some(evt) => tracing::warn!("Unexpected event: {evt:?}"),
+                        None => break,
                     }
-                    UiData::Jobs(jobs) => {
-                            let old_scrollbar_position = app.jobs_data.scrollbar_state.get_position();
-                            let scrollbar_state = ScrollbarState::new(jobs.len()).position(old_scrollbar_position);
-                            app.jobs_data = JobsData {
-                                jobs,
-                                scrollbar_state,
-                                table_state: app.jobs_data.table_state,
-                                sort_column: app.jobs_data.sort_column,
-                                sort_order: app.jobs_data.sort_order
-                            };
+                }
+                Some(app_event) = app_rx.recv() => {
+                    if let Event::DataLoaded { data } = app_event {
+                        app.apply_ui_data(data);
                     }
-                    UiData::JobDetails(details) => {
-                        app.job_details = Some(details);
+                }
+            }
+
+            tokio::task::yield_now().await;
+
+            if app.should_quit() {
+                tracing::info!("Stopping the Ballista TUI application!");
+                break;
+            }
+        }
+
+        Ok(())
+    }
+}
+
+#[cfg(feature = "web")]
+pub(crate) mod web {
+    use crate::tui::{
+        TuiResult,
+        app::{App, WebKeyAsyncAction},
+        domain::jobs::CancelJobResult,
+        event::{
+            Event, UiData,
+            web::{EventHandler, Sender},
+        },
+        http_client::HttpClient,
+        infrastructure::Settings,
+        terminal::TuiWrapper,
+        ui,
+    };
+
+    /// Entry point for the web browser TUI (WASM target). Sets up Ratzilla callbacks and starts
+    /// the Ratatui render loop via `draw_web`.
+    pub async fn main() -> TuiResult<()> {
+        use ratzilla::WebRenderer;
+        use std::cell::{Cell, RefCell};
+        use std::rc::Rc;
+        use wasm_bindgen_futures::spawn_local;
+
+        let config = Settings::new()?;
+        let tick_ms = u32::try_from(config.tick_interval_ms).unwrap_or(u32::MAX);
+
+        let wrapper = TuiWrapper::new()?;
+        let app = Rc::new(RefCell::new(App::new(config)?));
+
+        /// Reset the tick in flight flag on tick event handler drop.
+        struct ResetOnDrop(Rc<Cell<bool>>);
+        impl Drop for ResetOnDrop {
+            fn drop(&mut self) {
+                self.0.set(false);
+            }
+        }
+        let tick_in_flight = Rc::new(Cell::new(false));
+
+        let (tx, mut rx) = EventHandler::new(tick_ms, &wrapper.terminal);
+        app.borrow_mut().set_event_tx(tx.clone());
+
+        // Initial data load — http_client extracted with a brief borrow, no borrow held across await
+        {
+            tracing::info!("Initial data load");
+            let http_client = app.borrow().http_client.clone();
+            let tx = tx.clone();
+            spawn_local(async move {
+                let data = match http_client.get_scheduler_state().await {
+                    Ok(state) => UiData::SchedulerState(Some(state)),
+                    Err(e) => {
+                        tracing::error!("Failed to load scheduler state: {e:?}");
+                        UiData::SchedulerState(None)
                     }
-                    UiData::JobStagesGraph(graph) => {
-                        app.job_dot_popup = Some(graph);
+                };
+                tx.send(Event::DataLoaded { data }).await.ok();
+                tx.send(Event::Tick).await.ok();
+            });
+        }
+
+        // ── Render loop (driven by requestAnimationFrame) ─────────────────
+        let app_render = Rc::clone(&app);
+        wrapper.terminal.draw_web(move |f| {
+            while let Some(event) = rx.next() {
+                match event {
+                    Event::DataLoaded { data } => {
+                        // Brief synchronous borrow — safe, no await held
+                        app.borrow_mut().apply_ui_data(data);
                     }
-                    UiData::JobStagesData(job_id, stages) => {
-                        app.job_stages_popup = Some(JobStagesPopup::new(job_id, stages));
+                    Event::Tick => {
+                        if tick_in_flight.get() {
+                            continue;
+                        }
+                        tick_in_flight.set(true);
+                        // Extract state with brief borrows, then spawn async data load
+                        let http_client = app.borrow().http_client.clone();
+                        let is_executors = app.borrow().is_executors_view();
+                        let is_jobs = app.borrow().is_jobs_view();
+                        let tx = tx.clone();
+                        let _tick_in_flight_done =
+                            ResetOnDrop(Rc::clone(&tick_in_flight));
+                        spawn_local(async move {
+                            let data = load_tick_data_for_view(
+                                &http_client,
+                                is_executors,
+                                is_jobs,
+                            )
+                            .await;
+                            tx.send(Event::DataLoaded { data }).await.ok();
+                        });
                     }
-                    UiData::ExecutorDetails(executor) => {
-                        app.executor_details_popup =
-                            Some(ExecutorDetailsPopup::new(executor));
+                    Event::Key(key_event) => {
+                        // on_key_sync is purely synchronous — brief borrow_mut, no await
+                        let action = app.borrow_mut().on_key_sync(&key_event);
+                        if let Some(action) = action {
+                            let http_client = app.borrow().http_client.clone();
+                            let is_executors = app.borrow().is_executors_view();
+                            let is_jobs = app.borrow().is_jobs_view();
+                            let tx = tx.clone();
+                            spawn_local(async move {
+                                execute_web_async_action(
+                                    http_client,
+                                    tx,
+                                    is_executors,
+                                    is_jobs,
+                                    action,
+                                )
+                                .await;
+                            });
+                        }
                     }
-                  }
                 }
             }
-        }
 
-        tokio::task::yield_now().await;
+            let app = app_render.borrow();
+            ui::render(f, &app);
+        });
 
-        if app.should_quit() {
-            tracing::info!("Stopping the Ballista TUI application!");
-            break;
+        Ok(())
+    }
+
+    /// Loads data for the currently active view without borrowing `App` during the await.
+    async fn load_tick_data_for_view(
+        http_client: &HttpClient,
+        is_executors: bool,
+        is_jobs: bool,
+    ) -> UiData {
+        if is_executors {
+            let (scheduler_result, executors_result, jobs_result) = tokio::join!(
+                http_client.get_scheduler_state(),
+                http_client.get_executors(),
+                http_client.get_jobs(),
+            );
+            UiData::Executors(
+                scheduler_result
+                    .map_err(|e| tracing::error!("Failed to load scheduler state: {e:?}"))
+                    .ok(),
+                executors_result.unwrap_or_else(|e| {
+                    tracing::error!("Failed to load executors: {e:?}");
+                    vec![]
+                }),
+                jobs_result.unwrap_or_else(|e| {
+                    tracing::error!("Failed to load jobs: {e:?}");
+                    vec![]
+                }),
+            )
+        } else if is_jobs {
+            UiData::Jobs(http_client.get_jobs().await.unwrap_or_else(|e| {
+                tracing::error!("Failed to load jobs: {e:?}");
+                vec![]
+            }))
+        } else {
+            UiData::Metrics(http_client.get_metrics().await.unwrap_or_else(|e| {
+                tracing::error!("Failed to load metrics: {e:?}");
+                vec![]
+            }))
         }
     }
 
-    Ok(())
+    /// Executes an async action triggered by a key press, sending the result through `tx`.
+    // Captures only `Arc<HttpClient>` and `Sender<Event>` — no `Rc<RefCell<App>>` borrow.
+    async fn execute_web_async_action(
+        http_client: std::sync::Arc<HttpClient>,
+        tx: Sender<Event>,
+        is_executors: bool,
+        is_jobs: bool,
+        action: WebKeyAsyncAction,
+    ) {
+        async fn send_data(data: UiData, tx: Sender<Event>) {
+            tx.send(Event::DataLoaded { data }).await.ok();
+        }
+
+        match action {
+            WebKeyAsyncAction::LoadJobStages(id) => {
+                match http_client.get_job_stages(&id).await {
+                    Ok(mut stages) => {
+                        stages
+                            .stages
+                            .sort_by_key(|s| s.id.parse::<u64>().unwrap_or(u64::MAX));
+                        send_data(UiData::JobStagesData(id, stages), tx).await;
+                    }
+                    Err(e) => {
+                        tracing::error!("Failed to load stages for job '{id}': {e:?}")
+                    }
+                }
+            }
+            WebKeyAsyncAction::LoadExecutorDetails(id) => {
+                match http_client.get_executor(&id).await {
+                    Ok(executor) => {
+                        send_data(UiData::ExecutorDetails(executor), tx).await;
+                    }
+                    Err(e) => tracing::error!("Failed to load executor '{id}': {e:?}"),
+                }
+            }
+            WebKeyAsyncAction::LoadJobDot(id) => match http_client.get_job_dot(&id).await
+            {
+                Ok(dot_content) => {
+                    let graph = ui::dot_parser::parse_dot(&id, &dot_content);
+                    send_data(UiData::JobStagesGraph(graph), tx).await;
+                }
+                Err(e) => tracing::error!("Failed to load job dot for '{id}': {e:?}"),
+            },
+            WebKeyAsyncAction::CancelJob(id) => {
+                let result = match http_client.cancel_job(&id).await {
+                    Ok(resp) if resp.canceled => CancelJobResult::Success { job_id: id },
+                    Ok(_) => CancelJobResult::NotCanceled { job_id: id },
+                    Err(e) => CancelJobResult::Failure {
+                        job_id: id,
+                        error: e.to_string(),
+                    },
+                };
+                send_data(UiData::CancelJobResult(result), tx).await;
+            }
+            WebKeyAsyncAction::UpdateJobDetails(job_id) => {
+                if let Some(id) = job_id {
+                    match http_client.get_job_details(&id).await {
+                        Ok(details) => {
+                            send_data(UiData::JobDetails(details), tx).await;
+                        }
+                        Err(e) => {
+                            tracing::error!(
+                                "Failed to load job details for '{id}': {e:?}"
+                            )
+                        }
+                    }
+                }
+            }
+            WebKeyAsyncAction::ReloadView => {
+                let data =
+                    load_tick_data_for_view(&http_client, is_executors, is_jobs).await;
+                send_data(data, tx).await;
+            }
+        }
+    }
 }
diff --git a/ballista-cli/src/tui/terminal.rs b/ballista-cli/src/tui/terminal.rs
index 73c61f76f..a076b9bd3 100644
--- a/ballista-cli/src/tui/terminal.rs
+++ b/ballista-cli/src/tui/terminal.rs
@@ -15,53 +15,103 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use crate::tui::TuiResult;
-use crossterm::{
-    execute,
-    terminal::{
-        EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
-    },
-};
-use ratatui::{Terminal, backend::CrosstermBackend};
-use std::io::{self, Stdout};
-
-pub type Tui = Terminal<CrosstermBackend<Stdout>>;
-
-fn init() -> TuiResult<Tui> {
-    execute!(io::stdout(), EnterAlternateScreen)?;
-    enable_raw_mode()?;
-    let backend = CrosstermBackend::new(io::stdout());
-    let mut terminal = Terminal::new(backend)?;
-    terminal.hide_cursor()?;
-    terminal.clear()?;
-
-    let original_hook = std::panic::take_hook();
-    std::panic::set_hook(Box::new(move |panic_info| {
-        let _ = restore();
-        original_hook(panic_info);
-    }));
-
-    Ok(terminal)
-}
+// Native (crossterm) terminal backend
+#[cfg(not(feature = "web"))]
+mod tui {
+    use crate::tui::TuiResult;
+    use crossterm::{
+        execute,
+        terminal::{
+            EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
+        },
+    };
+    use ratatui::{Terminal, backend::CrosstermBackend};
+    use std::io::{self, Stdout};
 
-fn restore() -> TuiResult<()> {
-    execute!(io::stdout(), LeaveAlternateScreen)?;
-    disable_raw_mode()?;
-    Ok(())
-}
+    pub type Tui = Terminal<CrosstermBackend<Stdout>>;
 
-pub struct TuiWrapper {
-    pub terminal: Tui,
-}
+    fn init() -> TuiResult<Tui> {
+        execute!(io::stdout(), EnterAlternateScreen)?;
+        enable_raw_mode()?;
+        let backend = CrosstermBackend::new(io::stdout());
+        let mut terminal = Terminal::new(backend)?;
+        terminal.hide_cursor()?;
+        terminal.clear()?;
+
+        let original_hook = std::panic::take_hook();
+        std::panic::set_hook(Box::new(move |panic_info| {
+            let _ = restore();
+            original_hook(panic_info);
+        }));
+
+        Ok(terminal)
+    }
+
+    pub fn restore() -> TuiResult<()> {
+        let mut first_err: Option<std::io::Error> = None;
+
+        if let Err(e) = disable_raw_mode() {
+            tracing::error!("Failed to disable raw mode: {e:?}");
+            first_err = Some(e);
+        }
+
+        if let Err(e) = execute!(io::stdout(), LeaveAlternateScreen) {
+            tracing::error!("Failed to leave alternate screen: {e:?}");
+            if first_err.is_none() {
+                first_err = Some(e);
+            }
+        }
+
+        if let Some(e) = first_err {
+            return Err(e.into());
+        }
+        Ok(())
+    }
 
-impl TuiWrapper {
-    pub fn new() -> TuiResult<Self> {
-        Ok(Self { terminal: init()? })
+    pub struct TuiWrapper {
+        pub terminal: Tui,
+    }
+
+    impl TuiWrapper {
+        pub fn new() -> TuiResult<Self> {
+            Ok(Self { terminal: init()? })
+        }
+    }
+
+    impl Drop for TuiWrapper {
+        fn drop(&mut self) {
+            let _ = restore();
+        }
     }
 }
 
-impl Drop for TuiWrapper {
-    fn drop(&mut self) {
-        let _ = restore();
+// Web (Ratzilla) terminal backend
+#[cfg(feature = "web")]
+mod web {
+    use crate::tui::TuiResult;
+    use ratatui::Terminal;
+    use ratzilla::WebGl2Backend;
+
+    pub type WebTui = Terminal<WebGl2Backend>;
+
+    pub struct TuiWrapper {
+        pub terminal: WebTui,
+    }
+
+    impl TuiWrapper {
+        pub fn new() -> TuiResult<Self> {
+            let backend = WebGl2Backend::new().map_err(|e| {
+                crate::tui::error::TuiError::IO(std::io::Error::other(e.to_string()))
+            })?;
+            Ok(Self {
+                terminal: Terminal::new(backend)?,
+            })
+        }
     }
 }
+
+#[cfg(not(feature = "web"))]
+pub use tui::TuiWrapper;
+
+#[cfg(feature = "web")]
+pub use web::TuiWrapper;
diff --git a/ballista-cli/src/tui/ui/header/ballista-logo.svg b/ballista-cli/src/tui/ui/header/ballista-logo.svg
new file mode 100644
index 000000000..3f2ee3a3b
--- /dev/null
+++ b/ballista-cli/src/tui/ui/header/ballista-logo.svg
@@ -0,0 +1,29 @@
+<svg width="528" height="102" viewBox="0 0 528 102" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <path fill-rule="evenodd" clip-rule="evenodd"
+          d="M46.1418 102C53.6175 102 60.3725 96.0798 65.2024 86.5499C77.1095 87.7913 86.7259 84.9975 90.7636 78.0233C94.7201 71.1894 92.5126 61.7364 85.8066 52.2678C92.3397 42.9047 94.4553 33.585 90.5426 26.8267C86.676 20.1481 77.6936 17.303 66.4829 18.164C61.6066 7.05696 54.3037 0 46.1418 0C37.9408 0 30.6072 7.1246 25.7309 18.3236C14.7597 17.5876 5.99204 20.4469 2.18467 27.0233C-1.70042 33.7339 0.358023 42.9699 6.78315 52.2663C0.184612 61.6696 -1.96566 71.0398 1.96364 77.8267C5.94278 8 [...]
+          fill="url(#paint0_linear_805_84)"/>
+    <path d="M114 30.0994H139.765C145.173 30.0994 149.817 31.658 153.698 34.7753C157.642 37.829 159.614 42.4413 159.614 48.6123C159.614 51.8568 158.946 54.7514 157.61 57.2961C156.274 59.8409 154.747 61.6222 153.03 62.64C155.511 63.3398 157.801 65.0893 159.9 67.8885C162 70.6241 163.05 74.2504 163.05 78.7672C163.05 84.4292 161.046 89.0097 157.038 92.5087C153.093 96.0077 148.227 97.7572 142.437 97.7572H114V30.0994ZM126.406 42.1232V58.823H138.716C141.26 58.7593 143.233 57.9959 144.632 56.532 [...]
+          fill="#fff"/>
+    <path d="M214.114 82.8706H186.631L181.001 97.7572H168.405L194.361 30.0994H207.243L233.677 97.7572H219.935L214.114 82.8706ZM210.297 72.7553L200.277 47.0854L190.544 72.7553H210.297Z"
+          fill="#fff"/>
+    <path d="M253.808 85.3517H281.673V97.7572H240.926V30.0994H253.808V85.3517Z" fill="#fff"/>
+    <path d="M303.665 85.3517H331.53V97.7572H290.782V30.0994H303.665V85.3517Z" fill="#fff"/>
+    <path d="M353.522 97.7572H340.639V30.0994H353.522V97.7572Z" fill="#ffffff"/>
+    <path d="M389.092 28C395.899 28 401.784 29.495 406.746 32.4851L401.116 43.1729C396.535 41.2007 392.082 40.2147 387.756 40.2147C384.511 40.2147 381.935 40.8827 380.026 42.2186C378.181 43.491 377.259 45.3359 377.259 47.7534C377.259 49.9164 378.34 51.7614 380.503 53.2882C382.73 54.7514 386.07 56.3419 390.523 58.0595C394.977 59.7136 398.221 61.1132 400.257 62.2583C407.191 66.139 410.69 71.6738 410.754 78.8627C410.754 85.3517 408.368 90.473 403.597 94.2264C398.825 97.9163 392.909 99.7612  [...]
+          fill="#fff"/>
+    <path d="M467.472 42.5049H447.241V97.7572H434.359V42.5049H414.51V30.0994H467.472V42.5049Z" fill="#fff"/>
+    <path d="M508.224 82.8706H480.741L475.11 97.7572H462.514L488.47 30.0994H501.353L527.786 97.7572H514.045L508.224 82.8706ZM504.407 72.7553L494.387 47.0854L484.653 72.7553H504.407Z"
+          fill="#fff"/>
+    <path d="M123.499 13.14H117.419L115.919 17H114.079L119.579 2.82H121.419L126.979 17H124.999L123.499 13.14ZM122.879 11.6L120.439 5.3L117.999 11.6H122.879ZM140.732 2.82H145.792C147.072 2.82 148.152 3.2 149.032 3.96C149.912 4.70667 150.352 5.76 150.352 7.12C150.352 8.48 149.912 9.54 149.032 10.3C148.152 11.06 147.072 11.44 145.792 11.44L142.592 11.42V17H140.732V2.82ZM142.592 9.64H145.812C146.558 9.65333 147.178 9.43333 147.672 8.98C148.165 8.52667 148.412 7.90667 148.412 7.12C148.412 6.3 [...]
+          fill="#fff"/>
+    <path d="M509.863 3.97065H508.28V8.29376H507.272V3.97065H505.719V3H509.863V3.97065Z" fill="#fff"/>
+    <path d="M515.719 8.29376H514.748V4.8965L513.359 7.51724H512.717L511.328 4.8965L511.321 8.29376H510.38V3H511.328L513.076 6.233L514.748 3H515.711L515.719 8.29376Z"
+          fill="#fff"/>
+    <defs>
+        <linearGradient id="paint0_linear_805_84" x1="0" y1="0" x2="111" y2="27.0674" gradientUnits="userSpaceOnUse">
+            <stop stop-color="#5800FF"/>
+            <stop offset="0.489583" stop-color="#E900FF"/>
+            <stop offset="1" stop-color="#FFC600"/>
+        </linearGradient>
+    </defs>
+</svg>
diff --git a/ballista-cli/src/tui/ui/header/mod.rs b/ballista-cli/src/tui/ui/header/mod.rs
index 7c4eca2ca..2560b8a7c 100644
--- a/ballista-cli/src/tui/ui/header/mod.rs
+++ b/ballista-cli/src/tui/ui/header/mod.rs
@@ -51,6 +51,7 @@ pub(super) fn render_header(f: &mut Frame, area: Rect, app: &App) {
     render_navbar(f, chunks[1], app);
 }
 
+#[cfg(not(feature = "web"))]
 fn render_banner(f: &mut Frame, area: Rect) {
     use tui_big_text::{BigText, PixelSize};
 
@@ -63,11 +64,19 @@ fn render_banner(f: &mut Frame, area: Rect) {
     let big_text = BigText::builder()
         .pixel_size(banner_size)
         .style(Style::new().yellow())
-        .lines(vec![" Apache".into(), " DataFusion".into()])
+        .lines(vec![" DataFusion".into(), " Ballista".into()])
         .build();
     f.render_widget(big_text, area);
 }
 
+// Web: logo is displayed as a DOM <img> element overlaid on the Ratzilla canvas.
+// Halfblock characters are not reliably present in Ratzilla's WebGL2 glyph atlas,
+// so we leave this area blank and rely on setup_logo_dom() in wasm.rs.
+#[cfg(feature = "web")]
+fn render_banner(f: &mut Frame, area: Rect) {
+    f.render_widget(Block::default(), area);
+}
+
 fn render_navbar(f: &mut Frame, area: Rect, app: &App) {
     let chunks = Layout::default()
         .direction(Direction::Vertical)
diff --git a/ballista-cli/src/tui/ui/main/executors/executors_table.rs b/ballista-cli/src/tui/ui/main/executors/executors_table.rs
index 970f0a5d7..62ef94da9 100644
--- a/ballista-cli/src/tui/ui/main/executors/executors_table.rs
+++ b/ballista-cli/src/tui/ui/main/executors/executors_table.rs
@@ -46,7 +46,10 @@ pub fn render_executors(f: &mut Frame, area: Rect, app: &App) {
 }
 
 fn render_executors_table(frame: &mut Frame, area: Rect, app: &App) {
-    let header_style = Style::default().fg(Color::Yellow).bg(Color::Black);
+    let header_style = Style::default()
+        .fg(Color::LightYellow)
+        .bg(Color::Black)
+        .bold();
 
     let sort_column = &app.executors_data.sort_column;
     let sort_order = &app.executors_data.sort_order;
diff --git a/ballista-cli/src/tui/ui/main/executors/mod.rs b/ballista-cli/src/tui/ui/main/executors/mod.rs
index ebae63d61..920a4e6a5 100644
--- a/ballista-cli/src/tui/ui/main/executors/mod.rs
+++ b/ballista-cli/src/tui/ui/main/executors/mod.rs
@@ -27,13 +27,14 @@ use ratatui::{
     widgets::Clear,
 };
 
+#[cfg(not(feature = "web"))]
 use crate::tui::{
     TuiResult,
-    app::App,
-    domain::executors::Executor,
     event::{Event, UiData},
 };
+use crate::tui::{app::App, domain::executors::Executor};
 
+#[cfg(not(feature = "web"))]
 pub async fn load_executor_details_popup(app: &App, executor_id: &str) -> TuiResult<()> {
     let executor = app.http_client.get_executor(executor_id).await?;
     app.send_event(Event::DataLoaded {
@@ -59,6 +60,7 @@ pub fn render_executors(f: &mut Frame, area: Rect, app: &App) {
     }
 }
 
+#[cfg(not(feature = "web"))]
 pub async fn load_executors_data(app: &App) -> TuiResult<()> {
     let (scheduler_result, executors_result, jobs_result) = tokio::join!(
         app.http_client.get_scheduler_state(),
diff --git a/ballista-cli/src/tui/ui/main/jobs/mod.rs b/ballista-cli/src/tui/ui/main/jobs/mod.rs
index 440faf4b1..447838ded 100644
--- a/ballista-cli/src/tui/ui/main/jobs/mod.rs
+++ b/ballista-cli/src/tui/ui/main/jobs/mod.rs
@@ -15,21 +15,24 @@
 // specific language governing permissions and limitations
 // under the License.
 
-mod dot_parser;
+pub(crate) mod dot_parser;
 pub mod job_dot_popup;
 pub mod job_plan_popup;
 pub mod job_stages_popup;
 pub mod stage_plan_popup;
 pub mod stage_tasks_popup;
 
+#[cfg(not(feature = "web"))]
 use crate::tui::{
     TuiResult,
+    event::{Event, UiData},
+};
+use crate::tui::{
     app::App,
     domain::{
         SortOrder,
         jobs::{Job, SortColumn},
     },
-    event::{Event, UiData},
     ui::search_box::render_search_box,
     ui::vertical_scrollbar::render_scrollbar,
 };
@@ -46,6 +49,7 @@ use ratatui::{
     },
 };
 
+#[cfg(not(feature = "web"))]
 pub async fn load_jobs_data(app: &App) -> TuiResult<()> {
     let jobs = app.http_client.get_jobs().await.unwrap_or_else(|e| {
         tracing::error!("Failed to load the jobs: {e:?}");
@@ -58,6 +62,7 @@ pub async fn load_jobs_data(app: &App) -> TuiResult<()> {
     .await
 }
 
+#[cfg(not(feature = "web"))]
 pub async fn load_job_dot(app: &App, job_id: &str) -> TuiResult<()> {
     match app.http_client.get_job_dot(job_id).await {
         Ok(dot_content) => {
@@ -74,6 +79,7 @@ pub async fn load_job_dot(app: &App, job_id: &str) -> TuiResult<()> {
     }
 }
 
+#[cfg(not(feature = "web"))]
 pub async fn load_job_stages_popup(app: &App, job_id: &str) -> TuiResult<()> {
     let mut stages = app
         .http_client
@@ -94,6 +100,7 @@ pub async fn load_job_stages_popup(app: &App, job_id: &str) -> TuiResult<()> {
     .await
 }
 
+#[cfg(not(feature = "web"))]
 pub async fn load_job_details(app: &App, job_id: &str) -> TuiResult<()> {
     let details = match app.http_client.get_job_details(job_id).await {
         Ok(d) => d,
diff --git a/ballista-cli/src/tui/ui/main/metrics/mod.rs b/ballista-cli/src/tui/ui/main/metrics/mod.rs
index 0f0a0ce16..9e210fde9 100644
--- a/ballista-cli/src/tui/ui/main/metrics/mod.rs
+++ b/ballista-cli/src/tui/ui/main/metrics/mod.rs
@@ -15,17 +15,21 @@
 // specific language governing permissions and limitations
 // under the License.
 
+#[cfg(not(feature = "web"))]
 use crate::tui::{
     TuiResult,
+    event::{Event, UiData},
+};
+use crate::tui::{
     app::App,
     domain::{
         SortOrder,
         metrics::{Metric, SortColumn},
     },
-    event::{Event, UiData},
     ui::search_box::render_search_box,
     ui::vertical_scrollbar::render_scrollbar,
 };
+
 use prometheus_parse::HistogramCount;
 
 use crate::tui::ui::vertical_scrollbar;
@@ -40,14 +44,12 @@ use ratatui::{
     },
 };
 
+#[cfg(not(feature = "web"))]
 pub async fn load_metrics_data(app: &App) -> TuiResult<()> {
-    let metrics = match app.http_client.get_metrics().await {
-        Ok(metrics) => metrics,
-        Err(e) => {
-            tracing::error!("Failed to load the metrics: {e:?}");
-            Vec::new()
-        }
-    };
+    let metrics = app.http_client.get_metrics().await.unwrap_or_else(|e| {
+        tracing::error!("Failed to load the metrics: {e:?}");
+        Vec::new()
+    });
 
     app.send_event(Event::DataLoaded {
         data: UiData::Metrics(metrics),
@@ -132,7 +134,10 @@ fn render_metrics_table(
     metrics: &[&Metric],
     state: &mut TableState,
 ) {
-    let header_style = Style::default().fg(Color::Yellow).bg(Color::Black);
+    let header_style = Style::default()
+        .fg(Color::LightYellow)
+        .bg(Color::Black)
+        .bold();
 
     let sort_column = &app.metrics_data.sort_column;
     let sort_order = &app.metrics_data.sort_order;
diff --git a/ballista-cli/src/tui/ui/main/mod.rs b/ballista-cli/src/tui/ui/main/mod.rs
index 574050014..5d66c07f8 100644
--- a/ballista-cli/src/tui/ui/main/mod.rs
+++ b/ballista-cli/src/tui/ui/main/mod.rs
@@ -19,13 +19,18 @@ mod executors;
 mod jobs;
 mod metrics;
 
-pub use executors::{
-    executor_details_popup, load_executor_details_popup, load_executors_data,
-    render_executors,
-};
+pub use executors::{executor_details_popup, render_executors};
+#[cfg(not(feature = "web"))]
+pub use executors::{load_executor_details_popup, load_executors_data};
+#[cfg(not(feature = "web"))]
+pub use jobs::{load_job_details, load_job_dot, load_job_stages_popup, load_jobs_data};
+
+#[cfg(feature = "web")]
+pub(crate) use jobs::dot_parser;
 pub use jobs::{
-    job_dot_popup, job_plan_popup, job_stages_popup, load_job_details, load_job_dot,
-    load_job_stages_popup, load_jobs_data, render_jobs, stage_plan_popup,
+    job_dot_popup, job_plan_popup, job_stages_popup, render_jobs, stage_plan_popup,
     stage_tasks_popup,
 };
-pub use metrics::{load_metrics_data, render_metrics};
+#[cfg(not(feature = "web"))]
+pub use metrics::load_metrics_data;
+pub use metrics::render_metrics;
diff --git a/ballista-cli/src/tui/ui/mod.rs b/ballista-cli/src/tui/ui/mod.rs
index dbe69a7d9..744bce5ad 100644
--- a/ballista-cli/src/tui/ui/mod.rs
+++ b/ballista-cli/src/tui/ui/mod.rs
@@ -24,16 +24,20 @@ mod scheduler_info_popup;
 mod search_box;
 mod vertical_scrollbar;
 
-use crate::tui::app::App;
-use crate::tui::ui::header::render_header;
+use super::{app::App, ui::header::render_header};
 use footer::render_footer;
+#[cfg(feature = "web")]
+pub(crate) use main::dot_parser;
 pub use main::{
     executor_details_popup, job_dot_popup, job_plan_popup, job_stages_popup,
+    render_executors, render_jobs, render_metrics, stage_plan_popup, stage_tasks_popup,
+};
+#[cfg(not(feature = "web"))]
+pub use main::{
     load_executor_details_popup, load_executors_data, load_job_details, load_job_dot,
-    load_job_stages_popup, load_jobs_data, load_metrics_data, stage_plan_popup,
-    stage_tasks_popup,
+    load_job_stages_popup, load_jobs_data, load_metrics_data,
 };
-use main::{render_executors, render_jobs, render_metrics};
+
 use ratatui::{
     Frame,
     layout::{Constraint, Direction, Layout, Rect},
diff --git a/ballista-cli/src/wasm.rs b/ballista-cli/src/wasm.rs
new file mode 100644
index 000000000..d95afd871
--- /dev/null
+++ b/ballista-cli/src/wasm.rs
@@ -0,0 +1,88 @@
+// 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.
+
+//! # Ballista Web TUI application
+//!
+//! By using [`Ratzilla`](https://github.com/ratatui/ratzilla) the terminal user interface (TUI)
+//! application is built as a web application (a WASM32 module).
+//!
+//! ## Running the application
+//!
+//! Prerequisites:
+//!
+//! ```text
+//! cargo binstall trunk
+//! ```
+//!
+//! ### Local
+//!
+//! For local development, run the following command:
+//! ```text
+//! trunk serve --no-default-features --features web
+//! ```
+//! Then open [http://localhost:8080](http://localhost:8080) in your browser.
+//!
+//! ### Production
+//!
+//! To build the application for production, run the following command:
+//! ```text
+//! trunk build --cargo-profile release --minify --locked \
+//!             --no-default-features --features web
+//! ```
+//!
+//! See [`Trunk.toml`](https://github.com/apache/datafusion-ballista/blob/main/ballista-cli/Trunk.toml)
+//! for the build configuration.
+
+#![cfg(feature = "web")]
+
+use tracing_subscriber::fmt::format::Pretty;
+use tracing_subscriber::prelude::*;
+use tracing_web::{MakeWebConsoleWriter, performance_layer};
+use wasm_bindgen::prelude::*;
+use wasm_bindgen_futures::spawn_local;
+
+/// WASM entry point. Registered as the `start` function so the browser calls it
+/// automatically when the WASM module is loaded.
+#[wasm_bindgen(start)]
+pub fn wasm_start() {
+    console_error_panic_hook::set_once();
+    initialize_logging();
+
+    spawn_local(async move {
+        tracing::info!("Starting the Ballista WASM application");
+        if let Err(e) = crate::tui::main().await {
+            // unwrap_throw produces a JS exception visible in the browser console
+            wasm_bindgen::throw_str(&format!("Ballista TUI failed to start: {e}"));
+        }
+    });
+}
+
+fn initialize_logging() {
+    let fmt_layer = tracing_subscriber::fmt::layer()
+        .with_ansi(false) // No ANSI colors in browser console
+        .without_time() // std::time is not available in browsers
+        .with_writer(MakeWebConsoleWriter::new()); // write events to the console
+
+    let perf_layer = performance_layer().with_details_from_fields(Pretty::default());
+
+    tracing_subscriber::registry()
+        .with(fmt_layer)
+        .with(perf_layer)
+        .init(); // Install these as subscribers to tracing events
+
+    tracing::info!("WASM app initialized");
+}
diff --git a/ballista/scheduler/Cargo.toml b/ballista/scheduler/Cargo.toml
index a64cc02c8..9dab576ee 100644
--- a/ballista/scheduler/Cargo.toml
+++ b/ballista/scheduler/Cargo.toml
@@ -49,6 +49,7 @@ substrait = ["dep:datafusion-substrait"]
 arrow-flight = { workspace = true }
 async-trait = { workspace = true }
 axum = "0.8.9"
+tower-http = { version = "0.6", features = ["cors"] }
 ballista-core = { path = "../core", version = "53.0.0" }
 clap = { workspace = true, optional = true }
 dashmap = { workspace = true }
diff --git a/ballista/scheduler/src/api/routes.rs b/ballista/scheduler/src/api/routes.rs
index 533fff581..f88f5d86b 100644
--- a/ballista/scheduler/src/api/routes.rs
+++ b/ballista/scheduler/src/api/routes.rs
@@ -16,6 +16,7 @@ use axum::{Router, routing::get};
 use datafusion_proto::logical_plan::AsLogicalPlan;
 use datafusion_proto::physical_plan::AsExecutionPlan;
 use std::sync::Arc;
+use tower_http::cors::{AllowHeaders, AllowOrigin, CorsLayer};
 
 /// All routes configured for rest-api.
 pub fn get_routes<
@@ -61,5 +62,32 @@ pub fn get_routes<
         get(handlers::get_job_svg_graph::<T, U>),
     );
 
-    router.with_state(scheduler_server)
+    router.with_state(scheduler_server).layer(cors())
+}
+
+fn cors() -> CorsLayer {
+    let allowed_origins = std::env::var("BALLISTA_CORS_ALLOWED_ORIGINS")
+        .ok()
+        .and_then(|origins| {
+            origins
+                .split(',')
+                .map(|origin| origin.trim().parse().ok())
+                .collect::<Option<Vec<_>>>()
+        })
+        .unwrap_or_else(|| vec!["http://localhost:8080".parse().unwrap()]);
+
+    let allowed_methods = std::env::var("BALLISTA_CORS_ALLOWED_METHODS")
+        .ok()
+        .and_then(|methods| {
+            methods
+                .split(',')
+                .map(|method| method.trim().parse().ok())
+                .collect::<Option<Vec<_>>>()
+        })
+        .unwrap_or_else(|| vec![http::Method::GET, http::Method::PATCH]);
+
+    CorsLayer::new()
+        .allow_origin(AllowOrigin::list(allowed_origins))
+        .allow_methods(allowed_methods)
+        .allow_headers(AllowHeaders::any())
 }
diff --git a/ci/scripts/rust_clippy.sh b/ci/scripts/rust_clippy.sh
index 8c267b2bd..e0f72ce17 100755
--- a/ci/scripts/rust_clippy.sh
+++ b/ci/scripts/rust_clippy.sh
@@ -18,4 +18,11 @@
 # under the License.
 
 set -ex
-cargo clippy --all-targets --workspace --all-features -- -D warnings
+cargo clippy --all-targets --package ballista-core --all-features -- -D warnings
+cargo clippy --all-targets --package ballista-scheduler --all-features -- -D warnings
+cargo clippy --all-targets --package ballista-executor --all-features -- -D warnings
+cargo clippy --all-targets --package ballista --all-features -- -D warnings
+cargo clippy --all-targets --package ballista-cli --no-default-features --features cli,tui -- -D warnings
+cargo clippy --all-targets --package ballista-cli --no-default-features --features web -- -D warnings
+cargo clippy --all-targets --package ballista-examples --all-features -- -D warnings
+cargo clippy --all-targets --package ballista-benchmarks --all-features -- -D warnings
diff --git a/examples/Cargo.toml b/examples/Cargo.toml
index c328261de..2f6e55451 100644
--- a/examples/Cargo.toml
+++ b/examples/Cargo.toml
@@ -58,7 +58,7 @@ rustls = { version = "0.23", features = ["ring"], optional = true }
 [dev-dependencies]
 arrow-flight = { workspace = true }
 ballista = { path = "../ballista/client", version = "53.0.0" }
-ballista-core = { path = "../ballista/core", version = "53.0.0", default-features = false }
+ballista-core = { path = "../ballista/core", version = "53.0.0", default-features = false, features = ["build-binary"] }
 ballista-executor = { path = "../ballista/executor", version = "53.0.0", default-features = false }
 ballista-scheduler = { path = "../ballista/scheduler", version = "53.0.0", default-features = false }
 ctor = { workspace = true }


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@datafusion.apache.org
For additional commands, e-mail: commits-help@datafusion.apache.org