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

milenkovicm 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 e0c4032e2 feat(scheduler): Redirect '/' to nighlies.a.o WebTUI (#2059)
e0c4032e2 is described below

commit e0c4032e2cbab0d48d90587191093cc540390278
Author: Martin Grigorov <[email protected]>
AuthorDate: Wed Aug 5 19:17:44 2026 +0300

    feat(scheduler): Redirect '/' to nighlies.a.o WebTUI (#2059)
    
    * feat(scheduler): Redirect '/' to nighlies.a.o WebTUI
    
    Opening http://localhost:50050 in a browser will now redirect to the
    convenience deployment of the WebTUI app at
    https://nightlies.apache.org/datafusion/ballista/tui/...
    
    * Fix rsync delete command: `--delete does not work without --recursive or 
--dirs`
    
    * Re-enable the CI job condition to deploy WebTUI only on push to main
    
    * URL encode the query parameters
    
    Use x-forwarded-proto request header to read the url protocol for
    Scheduler server
    
    * Use "See Other" redirect (code 303)
    
    * Import std::collections::HashMap
    
    * Own the parsed query parameter values
    
    * Update python/Cargo.lock
    
    * Re-enable the CI condition for deployment at nightlies.a.o
    
    * Make it possible to use custom Axum route for the path redirecting to the 
WebTUI at https://nightlies.apache.org
    
    * Use config's external_host & bind_port as a fallback for the redirect url 
to WebTUI
    
    * clippy
    
    * Use X-Forwarded-XYZ headers to read the protocol+host+port
    
    Fallback to http://external_host:bind_port
    
    * fmt
    
    * clippy
    
    * Add unit tests for the get_webtui() Axum handler
    
    * taplo fmt
    
    * fix clippy
    
    ---------
    
    Co-authored-by: Marko Milenković <[email protected]>
---
 .github/workflows/web-tui.yml                 |   4 +-
 Cargo.lock                                    |  12 +-
 Cargo.toml                                    |   1 +
 ballista-cli/Cargo.toml                       |   6 +-
 ballista-cli/src/tui/http_client.rs           |   3 +-
 ballista-cli/src/tui/infrastructure/config.rs |  15 +-
 ballista/scheduler/Cargo.toml                 |   1 +
 ballista/scheduler/src/api/handlers.rs        | 297 +++++++++++++++++++++++++-
 ballista/scheduler/src/api/routes.rs          |   3 +
 ballista/scheduler/src/config.rs              |  34 ++-
 python/Cargo.lock                             |  10 +
 11 files changed, 360 insertions(+), 26 deletions(-)

diff --git a/.github/workflows/web-tui.yml b/.github/workflows/web-tui.yml
index d607676c2..7c182d945 100644
--- a/.github/workflows/web-tui.yml
+++ b/.github/workflows/web-tui.yml
@@ -90,7 +90,7 @@ jobs:
             --no-default-features --features web
 
       - name: Deploy at nightlies.a.o
-        if: ${{ github.event == 'push' && github.ref == 'refs/heads/main' }}
+        if: ${{ github.event_name == 'push' && github.ref_name == 'main' }}
         run: |
           TUI_DIR="datafusion/ballista/tui/${{ 
steps.cargo_metadata.outputs.ballista_version }}"
           REMOTE_TARGET_DIR="${{ secrets.NIGHTLIES_RSYNC_PATH }}/${TUI_DIR}/"
@@ -101,7 +101,7 @@ jobs:
 
           ssh -p ${{ secrets.NIGHTLIES_RSYNC_PORT }} -l ${{ 
secrets.NIGHTLIES_RSYNC_USER }} ${{ secrets.NIGHTLIES_RSYNC_HOST }} "mkdir -p 
${REMOTE_TARGET_DIR}"
 
-          rsync --times --compress --delete --verbose -e "ssh -p ${{ 
secrets.NIGHTLIES_RSYNC_PORT }} -l ${{ secrets.NIGHTLIES_RSYNC_USER }}" 
./target/web-tui/* ${{ secrets.NIGHTLIES_RSYNC_HOST }}:${REMOTE_TARGET_DIR}
+          rsync --times --compress --delete --recursive --verbose -e "ssh -p 
${{ secrets.NIGHTLIES_RSYNC_PORT }} -l ${{ secrets.NIGHTLIES_RSYNC_USER }}" 
./target/web-tui/* ${{ secrets.NIGHTLIES_RSYNC_HOST }}:${REMOTE_TARGET_DIR}
 
       - name: Upload WASM32 application
         uses: actions/upload-artifact@v7
diff --git a/Cargo.lock b/Cargo.lock
index ca8b6ef27..247de94cd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1084,7 +1084,6 @@ dependencies = [
  "gloo-timers",
  "js-sys",
  "mimalloc",
- "percent-encoding",
  "prometheus-parse",
  "ratatui",
  "ratzilla",
@@ -1098,6 +1097,7 @@ dependencies = [
  "tracing-subscriber",
  "tracing-web",
  "tui-shimmer",
+ "url-escape",
  "wasm-bindgen",
  "wasm-bindgen-futures",
  "web-sys",
@@ -1239,6 +1239,7 @@ dependencies = [
  "tracing",
  "tracing-appender",
  "tracing-subscriber",
+ "url-escape",
  "uuid",
 ]
 
@@ -7684,6 +7685,15 @@ dependencies = [
  "serde_derive",
 ]
 
+[[package]]
+name = "url-escape"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "fd4105a4fc511f6b1c9616a372a1313ae7c15afcaac839617c1c7cf25ce0a965"
+dependencies = [
+ "percent-encoding",
+]
+
 [[package]]
 name = "urlencoding"
 version = "2.1.3"
diff --git a/Cargo.toml b/Cargo.toml
index b29fc92dc..5a6b966dd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -81,6 +81,7 @@ tempfile = { version = "3.27" }
 tokio = { version = "1" }
 tokio-stream = { version = "0.1" }
 url = { version = "2.5" }
+url-escape = { version = "0.1.2" }
 uuid = { version = "1.23", features = ["v4", "v7"] }
 
 # cargo build --profile release-lto
diff --git a/ballista-cli/Cargo.toml b/ballista-cli/Cargo.toml
index 573c1de6e..a61a63247 100644
--- a/ballista-cli/Cargo.toml
+++ b/ballista-cli/Cargo.toml
@@ -46,7 +46,6 @@ config = { version = "0.15.23", default-features = false, 
features = ["yaml"], o
 dirs = "6.0"
 dotparser = { version = "0.3.0", optional = true }
 futures = { version = "0.3.31", optional = true }
-percent-encoding = { version = "2.3.2", optional = true }
 prometheus-parse = { version = "0.2", optional = true }
 ratatui = { version = "0.30.1", default-features = false, optional = true }
 reqwest = { version = "0.13.4", features = ["json"], optional = true }
@@ -55,6 +54,7 @@ serde_json = { version = "1", optional = true }
 tracing = { version = "0.1" }
 tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", 
"local-time"] }
 tui-shimmer = { version = "0.1", optional = true }
+url-escape = { workspace = true, optional = true }
 
 # Native-only TUI deps
 crossterm = { version = "0.29.0", features = ["event-stream"], optional = true 
}
@@ -94,7 +94,7 @@ tui = [
     "dep:crossterm",
     "dep:dotparser",
     "dep:futures",
-    "dep:percent-encoding",
+    "dep:url-escape",
     "dep:prometheus-parse",
     "dep:ratatui",
     "dep:reqwest",
@@ -116,7 +116,7 @@ web = [
     "dep:critical-section",
     "dep:dotparser",
     "dep:futures",
-    "dep:percent-encoding",
+    "dep:url-escape",
     "dep:prometheus-parse",
     "dep:ratatui",
     "ratatui/serde",
diff --git a/ballista-cli/src/tui/http_client.rs 
b/ballista-cli/src/tui/http_client.rs
index bccabec64..a315c734a 100644
--- a/ballista-cli/src/tui/http_client.rs
+++ b/ballista-cli/src/tui/http_client.rs
@@ -15,7 +15,6 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use percent_encoding::{NON_ALPHANUMERIC, percent_encode};
 use reqwest::{Client, Response};
 use serde::de::DeserializeOwned;
 #[cfg(not(target_arch = "wasm32"))]
@@ -213,6 +212,6 @@ impl HttpClient {
     }
 
     fn url_encode(&self, job_id: &str) -> String {
-        percent_encode(job_id.as_bytes(), NON_ALPHANUMERIC).to_string()
+        url_escape::encode_path(job_id).to_string()
     }
 }
diff --git a/ballista-cli/src/tui/infrastructure/config.rs 
b/ballista-cli/src/tui/infrastructure/config.rs
index 2d64f0710..ffba3dbb4 100644
--- a/ballista-cli/src/tui/infrastructure/config.rs
+++ b/ballista-cli/src/tui/infrastructure/config.rs
@@ -114,9 +114,9 @@ mod web {
         pub(super) fn parse() -> File<FileSourceString, FileFormat> {
             let mut data_reload_interval_ms = 2000;
             let mut repaint_interval_ms = 50;
-            let mut scheduler_url = "http://localhost:50050";;
+            let mut scheduler_url = String::from("http://localhost:50050";);
             let mut http_timeout_ms = 2000;
-            let mut theme_name = "dark";
+            let mut theme_name = String::from("dark");
             let mut theme_app_background_bg = String::from("black");
 
             let query_string = Self::decode_request();
@@ -131,13 +131,18 @@ mod web {
                         "ballista_repaint_interval_ms" => {
                             repaint_interval_ms = 
value.parse::<u64>().unwrap_or(50)
                         }
-                        "ballista_scheduler_url" => scheduler_url = value,
+                        "ballista_scheduler_url" => {
+                            scheduler_url = 
url_escape::decode(value).to_string()
+                        }
                         "ballista_http_timeout" => {
                             http_timeout_ms = 
value.parse::<u64>().unwrap_or(2000)
                         }
-                        "ballista_theme_name" => theme_name = value,
+                        "ballista_theme_name" => {
+                            theme_name = url_escape::decode(value).to_string()
+                        }
                         "ballista_theme_overrides_app_background_bg" => {
-                            theme_app_background_bg = value.replace("%23", 
"#");
+                            theme_app_background_bg =
+                                url_escape::decode(value).to_string();
                         }
                         _ => {}
                     }
diff --git a/ballista/scheduler/Cargo.toml b/ballista/scheduler/Cargo.toml
index 6409a87d3..b8b712773 100644
--- a/ballista/scheduler/Cargo.toml
+++ b/ballista/scheduler/Cargo.toml
@@ -77,6 +77,7 @@ tower-http = { version = "0.7", features = ["cors"] }
 tracing = { workspace = true, optional = true }
 tracing-appender = { workspace = true, optional = true }
 tracing-subscriber = { workspace = true, optional = true }
+url-escape = { workspace = true }
 uuid = { workspace = true }
 
 [[test]]
diff --git a/ballista/scheduler/src/api/handlers.rs 
b/ballista/scheduler/src/api/handlers.rs
index a0e1a60d5..5409f0780 100644
--- a/ballista/scheduler/src/api/handlers.rs
+++ b/ballista/scheduler/src/api/handlers.rs
@@ -17,6 +17,7 @@ use crate::state::execution_graph_dot::ExecutionGraphDot;
 use crate::state::execution_stage::TaskInfo;
 use crate::{api::SchedulerErrorResponse, scheduler_server::SchedulerServer};
 use axum::extract::Query;
+use axum::response::Redirect;
 use axum::{
     Json,
     extract::{Path, State},
@@ -46,8 +47,9 @@ use graphviz_rust::{
     exec,
     printer::PrinterContext,
 };
-use http::{StatusCode, header::CONTENT_TYPE};
+use http::{HeaderMap, StatusCode, header::CONTENT_TYPE};
 use serde::Serialize;
+use std::collections::HashMap;
 use std::sync::Arc;
 use std::time::Duration;
 
@@ -230,6 +232,58 @@ pub enum PlanFormat {
     Metrics,
 }
 
+/// A handler for GET requests to the root (`/`).
+/// It redirects to 
`https://nightlies.apache.org/datafusion/ballista/tui/<BALLISTA_VERSION>/`
+/// forwarding any query parameters
+pub async fn get_webtui<
+    T: AsLogicalPlan + Clone + Send + Sync + 'static,
+    U: AsExecutionPlan + Send + Sync + 'static,
+>(
+    header_map: HeaderMap,
+    Query(mut params): Query<HashMap<String, String>>,
+    State(data_server): State<Arc<SchedulerServer<T, U>>>,
+) -> Result<Redirect, (StatusCode, String)> {
+    const NIGHTLIES_URL: &str = 
"https://nightlies.apache.org/datafusion/ballista/tui";;
+    let external_host = &data_server.state.config.external_host;
+    let bind_port = data_server.state.config.bind_port;
+
+    let ballista_scheduler_url =
+        params.remove("ballista_scheduler_url").unwrap_or_else(|| {
+            let proto = header_map
+                .get("x-forwarded-proto")
+                .and_then(|v| v.to_str().ok())
+                .unwrap_or("http");
+            let host = header_map
+                .get("x-forwarded-host")
+                .and_then(|hv| hv.to_str().ok())
+                .unwrap_or(external_host);
+            let port = header_map
+                .get("x-forwarded-port")
+                .and_then(|hv| hv.to_str().ok())
+                .and_then(|v| v.parse::<u16>().ok())
+                .unwrap_or(bind_port);
+            format!("{proto}://{host}:{port}")
+        });
+
+    let mut query_string = String::new();
+    query_string.push_str(&format!(
+        "ballista_scheduler_url={}",
+        url_escape::encode_query(&ballista_scheduler_url)
+    ));
+
+    for (k, v) in params.iter() {
+        query_string.push_str(&format!(
+            "&{}={}",
+            url_escape::encode_query(k),
+            url_escape::encode_query(v)
+        ));
+    }
+
+    let target = format!("{NIGHTLIES_URL}/{BALLISTA_VERSION}/?{query_string}");
+
+    Ok(Redirect::to(&target))
+}
+
 pub async fn get_scheduler_state<
     T: AsLogicalPlan + Clone + Send + Sync + 'static,
     U: AsExecutionPlan + Send + Sync + 'static,
@@ -549,7 +603,7 @@ pub async fn get_query_stages<
                         let metrics = 
running_stage.stage_metrics.as_deref().unwrap_or(&[]);
                         summary.stage_plan = Some(match plan_format {
                             PlanFormat::Default => 
displayable(running_stage.plan.as_ref()).indent(false).to_string(),
-                            PlanFormat::Tree    => 
displayable(running_stage.plan.as_ref()).tree_render().to_string(),
+                            PlanFormat::Tree => 
displayable(running_stage.plan.as_ref()).tree_render().to_string(),
                             PlanFormat::Metrics => 
format_stage_metrics(running_stage.plan.as_ref(), metrics),
                         });
                         summary.input_rows = running_stage
@@ -599,7 +653,7 @@ pub async fn get_query_stages<
                                     finish_time: info.finish_time as u64,
                                     input_rows,
                                     output_rows,
-                                    status: task_status
+                                    status: task_status,
                                 })
                             })
                             .collect();
@@ -607,7 +661,7 @@ pub async fn get_query_stages<
                     ExecutionStage::Successful(completed_stage) => {
                         summary.stage_plan = Some(match plan_format {
                             PlanFormat::Default => 
displayable(completed_stage.plan.as_ref()).indent(false).to_string(),
-                            PlanFormat::Tree    => 
displayable(completed_stage.plan.as_ref()).tree_render().to_string(),
+                            PlanFormat::Tree => 
displayable(completed_stage.plan.as_ref()).tree_render().to_string(),
                             PlanFormat::Metrics => 
format_stage_metrics(completed_stage.plan.as_ref(), 
&completed_stage.stage_metrics),
                         });
                         summary.input_rows = get_combined_count(
@@ -648,7 +702,7 @@ pub async fn get_query_stages<
                                     finish_time: task_info.finish_time as u64,
                                     input_rows,
                                     output_rows,
-                                    status: task_status
+                                    status: task_status,
                                 })
                             })
                             .collect();
@@ -923,7 +977,7 @@ pub async fn get_job_dot_graph<
         })?
     {
         ExecutionGraphDot::generate(graph.as_ref())
-            .map_err(|e|  {
+            .map_err(|e| {
                 tracing::error!("Error occurred while getting the dot graph 
for job '{job_id}' reason: {e:?}");
                 SchedulerErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR)
             })
@@ -968,10 +1022,10 @@ pub async fn get_job_svg_graph<
                 &mut PrinterContext::default(),
                 vec![CommandArg::Format(Format::Svg)],
             )
-            .map_err(|e| {
-                tracing::error!("Error occurred while getting job svg graph 
for job '{job_id}' reason: {e:?}");
-                SchedulerErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR)
-            })?;
+                .map_err(|e| {
+                    tracing::error!("Error occurred while getting job svg 
graph for job '{job_id}' reason: {e:?}");
+                    
SchedulerErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR)
+                })?;
 
             let svg = String::from_utf8_lossy(&result).to_string();
             Ok(Response::builder()
@@ -1147,4 +1201,227 @@ mod tests {
     fn test_job_elapsed_ms_end_before_start_saturates_to_zero() {
         assert_eq!(super::job_elapsed_ms(500, 100), 0);
     }
+
+    mod get_webtui {
+        use super::*;
+        use crate::config::SchedulerConfig;
+        use crate::metrics::default_metrics_collector;
+        use crate::test_utils::test_cluster_context;
+        use axum::response::IntoResponse;
+        use ballista_core::serde::BallistaCodec;
+        use datafusion_proto::protobuf::{LogicalPlanNode, PhysicalPlanNode};
+
+        fn test_scheduler(
+            config: SchedulerConfig,
+        ) -> Arc<SchedulerServer<LogicalPlanNode, PhysicalPlanNode>> {
+            let server = SchedulerServer::new(
+                "localhost:50050".to_owned(),
+                test_cluster_context(),
+                BallistaCodec::default(),
+                Arc::new(config),
+                default_metrics_collector().unwrap(),
+            );
+            Arc::new(server)
+        }
+
+        fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
+            let mut map = HeaderMap::new();
+            for (k, v) in pairs {
+                map.insert(
+                    http::HeaderName::from_bytes(k.as_bytes()).unwrap(),
+                    v.parse().unwrap(),
+                );
+            }
+            map
+        }
+
+        fn location_of(result: Result<Redirect, (StatusCode, String)>) -> 
String {
+            let response = result.expect("handler should not 
error").into_response();
+            assert_eq!(response.status(), StatusCode::SEE_OTHER);
+            response
+                .headers()
+                .get("location")
+                .expect("redirect response must have a location header")
+                .to_str()
+                .unwrap()
+                .to_owned()
+        }
+
+        #[tokio::test]
+        async fn defaults_to_config_external_host_and_bind_port() {
+            let scheduler = test_scheduler(SchedulerConfig::default());
+
+            let result =
+                get_webtui(HeaderMap::new(), Query(HashMap::new()), 
State(scheduler))
+                    .await;
+
+            let location = location_of(result);
+            assert!(
+                
location.contains("ballista_scheduler_url=http://localhost:50050";),
+                "unexpected location: {location}"
+            );
+            assert!(
+                location
+                    
.starts_with("https://nightlies.apache.org/datafusion/ballista/tui/";)
+            );
+        }
+
+        #[tokio::test]
+        async fn uses_custom_external_host_and_bind_port_from_config() {
+            let scheduler = test_scheduler(SchedulerConfig {
+                external_host: "scheduler.example.com".into(),
+                bind_port: 8080,
+                ..Default::default()
+            });
+
+            let result =
+                get_webtui(HeaderMap::new(), Query(HashMap::new()), 
State(scheduler))
+                    .await;
+
+            let location = location_of(result);
+            assert!(
+                location
+                    
.contains("ballista_scheduler_url=http://scheduler.example.com:8080";),
+                "unexpected location: {location}"
+            );
+        }
+
+        #[tokio::test]
+        async fn x_forwarded_headers_take_precedence_over_config() {
+            let scheduler = test_scheduler(SchedulerConfig {
+                external_host: "internal-host".into(),
+                bind_port: 50050,
+                ..Default::default()
+            });
+
+            let result = get_webtui(
+                headers(&[
+                    ("x-forwarded-proto", "https"),
+                    ("x-forwarded-host", "public-host.example.com"),
+                    ("x-forwarded-port", "443"),
+                ]),
+                Query(HashMap::new()),
+                State(scheduler),
+            )
+            .await;
+
+            let location = location_of(result);
+            assert!(
+                location.contains(
+                    
"ballista_scheduler_url=https://public-host.example.com:443";
+                ),
+                "unexpected location: {location}"
+            );
+        }
+
+        #[tokio::test]
+        async fn partial_forwarded_headers_fall_back_to_config_for_the_rest() {
+            let scheduler = test_scheduler(SchedulerConfig {
+                external_host: "internal-host".into(),
+                bind_port: 50050,
+                ..Default::default()
+            });
+
+            let result = get_webtui(
+                headers(&[("x-forwarded-proto", "https")]),
+                Query(HashMap::new()),
+                State(scheduler),
+            )
+            .await;
+
+            let location = location_of(result);
+            assert!(
+                
location.contains("ballista_scheduler_url=https://internal-host:50050";),
+                "unexpected location: {location}"
+            );
+        }
+
+        #[tokio::test]
+        async fn malformed_forwarded_port_falls_back_to_config_bind_port() {
+            let scheduler = test_scheduler(SchedulerConfig {
+                external_host: "internal-host".into(),
+                bind_port: 50050,
+                ..Default::default()
+            });
+
+            let result = get_webtui(
+                headers(&[
+                    ("x-forwarded-proto", "https"),
+                    ("x-forwarded-port", "not-a-port"),
+                ]),
+                Query(HashMap::new()),
+                State(scheduler),
+            )
+            .await;
+
+            let location = location_of(result);
+            assert!(
+                
location.contains("ballista_scheduler_url=https://internal-host:50050";),
+                "unexpected location: {location}"
+            );
+        }
+
+        #[tokio::test]
+        async fn explicit_ballista_scheduler_url_param_takes_precedence() {
+            let scheduler = test_scheduler(SchedulerConfig::default());
+
+            let mut params = HashMap::new();
+            params.insert(
+                "ballista_scheduler_url".to_string(),
+                "https://override.example.com:1234".to_string(),
+            );
+
+            let result = get_webtui(
+                headers(&[("x-forwarded-host", 
"should-be-ignored.example.com")]),
+                Query(params),
+                State(scheduler),
+            )
+            .await;
+
+            let location = location_of(result);
+            assert!(
+                location
+                    
.contains("ballista_scheduler_url=https://override.example.com:1234";),
+                "unexpected location: {location}"
+            );
+            assert!(
+                !location.contains("should-be-ignored"),
+                "explicit ballista_scheduler_url must not be overridden: 
{location}"
+            );
+        }
+
+        #[tokio::test]
+        async fn additional_query_params_are_forwarded() {
+            let scheduler = test_scheduler(SchedulerConfig::default());
+
+            let mut params = HashMap::new();
+            params.insert("foo".to_string(), "bar".to_string());
+
+            let result =
+                get_webtui(HeaderMap::new(), Query(params), 
State(scheduler)).await;
+
+            let location = location_of(result);
+            assert!(
+                location.contains("&foo=bar"),
+                "unexpected location: {location}"
+            );
+        }
+
+        #[tokio::test]
+        async fn query_param_values_are_url_escaped() {
+            let scheduler = test_scheduler(SchedulerConfig::default());
+
+            let mut params = HashMap::new();
+            params.insert("weird".to_string(), "a#b c".to_string());
+
+            let result =
+                get_webtui(HeaderMap::new(), Query(params), 
State(scheduler)).await;
+
+            let location = location_of(result);
+            assert!(
+                location.contains("&weird=a%23b%20c"),
+                "unexpected location: {location}"
+            );
+        }
+    }
 }
diff --git a/ballista/scheduler/src/api/routes.rs 
b/ballista/scheduler/src/api/routes.rs
index 76f2b7851..6a3341934 100644
--- a/ballista/scheduler/src/api/routes.rs
+++ b/ballista/scheduler/src/api/routes.rs
@@ -27,7 +27,10 @@ pub fn get_routes<
 >(
     scheduler_server: Arc<SchedulerServer<T, U>>,
 ) -> Router {
+    let web_tui_route = scheduler_server.state.config.web_tui_route.clone();
+
     let router = Router::new()
+        .route(&web_tui_route, get(handlers::get_webtui))
         .route("/api/state", get(handlers::get_scheduler_state::<T, U>))
         .route("/api/version", get(handlers::get_scheduler_version))
         .route("/api/executors", get(handlers::get_executors::<T, U>))
diff --git a/ballista/scheduler/src/config.rs b/ballista/scheduler/src/config.rs
index 28320783f..c24face92 100644
--- a/ballista/scheduler/src/config.rs
+++ b/ballista/scheduler/src/config.rs
@@ -79,13 +79,26 @@ pub struct Config {
     )]
     pub advertise_flight_sql_endpoint: Option<String>,
     /// Namespace for the ballista cluster.
-    #[arg(short = 'n', long, default_value_t = String::from("ballista"), help 
= "Namespace for the ballista cluster that this executor will join.")]
+    #[arg(
+        short = 'n',
+        long,
+        default_value_t = String::from("ballista"),
+        help = "Namespace for the ballista cluster that this executor will 
join."
+    )]
     pub namespace: String,
     /// Local host name or IP address to bind to.
-    #[arg(long, default_value_t = String::from("0.0.0.0"), help = "Local host 
name or IP address to bind to.")]
+    #[arg(
+        long,
+        default_value_t = String::from("0.0.0.0"),
+        help = "Local host name or IP address to bind to."
+    )]
     pub bind_host: String,
     /// External host name for executors to connect to.
-    #[arg(long, default_value_t = String::from("localhost"), help = "Host name 
or IP address so that executors can connect to this scheduler.")]
+    #[arg(
+        long,
+        default_value_t = String::from("localhost"),
+        help = "Host name or IP address so that executors can connect to this 
scheduler."
+    )]
     pub external_host: String,
     /// Port to bind the scheduler gRPC service.
     #[arg(
@@ -267,6 +280,14 @@ pub struct Config {
         help = "Comma-separated list of allowed methods for CORS. By default, 
GET, PATCH, and OPTIONS are allowed."
     )]
     pub cors_allowed_methods: String,
+    #[cfg(feature = "rest-api")]
+    /// The HTTP path that will redirect to the WebTUI app at 
`https://nightlies.apache.org`
+    #[arg(
+        long,
+        default_value_t = String::from("/"),
+        help = "The HTTP path that will redirect to the WebTUI app at 
https://nightlies.apache.org.";
+    )]
+    pub web_tui_route: String,
 }
 
 /// Configurations for the ballista scheduler of scheduling jobs and tasks
@@ -344,6 +365,9 @@ pub struct SchedulerConfig {
     #[cfg(feature = "rest-api")]
     /// Comma-separated list of allowed methods for CORS
     pub cors_allowed_methods: String,
+    #[cfg(feature = "rest-api")]
+    /// The HTTP path that will redirect to the WebTUI app at 
`https://nightlies.apache.org`
+    pub web_tui_route: String,
     /// Callback invoked when new work becomes available for executors.
     /// See [`OnWorkAvailableFn`].
     pub on_work_available: Option<OnWorkAvailableFn>,
@@ -386,6 +410,8 @@ impl Default for SchedulerConfig {
             cors_allowed_origins: String::default(),
             #[cfg(feature = "rest-api")]
             cors_allowed_methods: String::default(),
+            #[cfg(feature = "rest-api")]
+            web_tui_route: String::from("/"),
             on_work_available: None,
         }
     }
@@ -645,6 +671,8 @@ impl TryFrom<Config> for SchedulerConfig {
             cors_allowed_origins: opt.cors_allowed_origins,
             #[cfg(feature = "rest-api")]
             cors_allowed_methods: opt.cors_allowed_methods,
+            #[cfg(feature = "rest-api")]
+            web_tui_route: opt.web_tui_route,
             on_work_available: None,
         };
 
diff --git a/python/Cargo.lock b/python/Cargo.lock
index 111a1019b..f8e6b0225 100644
--- a/python/Cargo.lock
+++ b/python/Cargo.lock
@@ -588,6 +588,7 @@ dependencies = [
  "tokio-stream",
  "tonic",
  "tower-http 0.7.0",
+ "url-escape",
  "uuid",
 ]
 
@@ -4344,6 +4345,15 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "url-escape"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "fd4105a4fc511f6b1c9616a372a1313ae7c15afcaac839617c1c7cf25ce0a965"
+dependencies = [
+ "percent-encoding",
+]
+
 [[package]]
 name = "utf8_iter"
 version = "1.0.4"


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to