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/05/15 10:00:15 UTC

(datafusion-ballista) branch main updated: [TUI] Add a config setting for rendering job stage's plan as a tree (#1704)

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 c05f1dc31 [TUI] Add a config setting for rendering job stage's plan as a tree (#1704)
c05f1dc31 is described below

commit c05f1dc31b17f6ffa6d4bd3bb5f82467a39c7074
Author: Martin Grigorov <ma...@users.noreply.github.com>
AuthorDate: Fri May 15 13:00:11 2026 +0300

    [TUI] Add a config setting for rendering job stage's plan as a tree (#1704)
    
    * [TUI] Add a config setting for rendering job stage's plan as a tree
    
    Related-to: #1650
    
    * Stage tasks popup is not scrollable
    
    * Fix the docs for Stage Plan popup scrolling
    
    * Reset the scroll position when showing a stage plan
    
    * Remove debug leftover
    
    * Encapsulate the plan popup scroll position
    
    * url encode the job_id for cancel_job http request
---
 ballista-cli/src/tui/app.rs                        | 11 +++--
 ballista-cli/src/tui/domain/jobs/stages.rs         | 57 ++++++++++++++--------
 ballista-cli/src/tui/http_client.rs                | 32 ++++++------
 ballista-cli/src/tui/infrastructure/config.rs      | 25 ++++++++++
 ballista-cli/src/tui/mod.rs                        |  2 +-
 ballista-cli/src/tui/ui/footer.rs                  |  9 ++--
 ballista-cli/src/tui/ui/main/jobs/mod.rs           | 11 ++---
 .../src/tui/ui/main/jobs/stage_plan_popup.rs       |  4 +-
 docs/source/user-guide/cli.md                      | 15 +++++-
 9 files changed, 112 insertions(+), 54 deletions(-)

diff --git a/ballista-cli/src/tui/app.rs b/ballista-cli/src/tui/app.rs
index 663087cc5..7d37baf24 100644
--- a/ballista-cli/src/tui/app.rs
+++ b/ballista-cli/src/tui/app.rs
@@ -201,10 +201,13 @@ impl App {
                 && let KeyCode::Esc = key.code
             {
                 popup.set_no_details_view();
-            } else if popup.is_plan_view()
-                && let KeyCode::Esc = key.code
-            {
-                popup.set_no_details_view();
+            } 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(),
+                    _ => {}
+                }
             } else if popup.is_no_details_view() {
                 match key.code {
                     KeyCode::Up => popup.scroll_up(),
diff --git a/ballista-cli/src/tui/domain/jobs/stages.rs b/ballista-cli/src/tui/domain/jobs/stages.rs
index f4d6aaad2..33d4a28b6 100644
--- a/ballista-cli/src/tui/domain/jobs/stages.rs
+++ b/ballista-cli/src/tui/domain/jobs/stages.rs
@@ -77,6 +77,7 @@ pub struct JobStagesPopup {
     pub stages: JobStagesResponse,
     pub table_state: TableState,
     details_view: StageDetailsView,
+    scroll_position: u16,
 }
 
 impl JobStagesPopup {
@@ -86,15 +87,21 @@ impl JobStagesPopup {
             stages,
             table_state: TableState::default(),
             details_view: StageDetailsView::None,
+            scroll_position: 0,
         }
     }
 
+    pub fn plan_scroll_position(&self) -> u16 {
+        self.scroll_position
+    }
+
     pub fn set_tasks_view(&mut self) {
         self.details_view = StageDetailsView::Tasks;
     }
 
     pub fn set_plan_view(&mut self) {
         self.details_view = StageDetailsView::Plan;
+        self.scroll_position = 0;
     }
 
     pub fn set_no_details_view(&mut self) {
@@ -114,36 +121,44 @@ impl JobStagesPopup {
     }
 
     pub fn scroll_down(&mut self) {
-        let len = self.stages.stages.len();
-        if len == 0 {
-            self.table_state.select(None);
-            return;
-        }
-        if let Some(selected) = self.table_state.selected() {
-            if selected < len - 1 {
-                self.table_state.select(Some(selected + 1));
-            } else {
+        if self.is_no_details_view() {
+            let len = self.stages.stages.len();
+            if len == 0 {
                 self.table_state.select(None);
+                return;
             }
-        } else {
-            self.table_state.select(Some(0));
+            if let Some(selected) = self.table_state.selected() {
+                if selected < len - 1 {
+                    self.table_state.select(Some(selected + 1));
+                } else {
+                    self.table_state.select(None);
+                }
+            } else {
+                self.table_state.select(Some(0));
+            }
+        } else if self.is_plan_view() {
+            self.scroll_position = self.scroll_position.saturating_add(1);
         }
     }
 
     pub fn scroll_up(&mut self) {
-        let len = self.stages.stages.len();
-        if len == 0 {
-            self.table_state.select(None);
-            return;
-        }
-        if let Some(selected) = self.table_state.selected() {
-            if selected == 0 {
+        if self.is_no_details_view() {
+            let len = self.stages.stages.len();
+            if len == 0 {
                 self.table_state.select(None);
+                return;
+            }
+            if let Some(selected) = self.table_state.selected() {
+                if selected == 0 {
+                    self.table_state.select(None);
+                } else {
+                    self.table_state.select(Some(selected - 1));
+                }
             } else {
-                self.table_state.select(Some(selected - 1));
+                self.table_state.select(Some(len - 1));
             }
-        } else {
-            self.table_state.select(Some(len - 1));
+        } else if self.is_plan_view() {
+            self.scroll_position = self.scroll_position.saturating_sub(1);
         }
     }
 
diff --git a/ballista-cli/src/tui/http_client.rs b/ballista-cli/src/tui/http_client.rs
index 5f142cd9a..1aa1862e9 100644
--- a/ballista-cli/src/tui/http_client.rs
+++ b/ballista-cli/src/tui/http_client.rs
@@ -34,22 +34,22 @@ use crate::tui::{
 };
 
 pub struct HttpClient {
-    scheduler_url: String,
+    config: Settings,
     client: reqwest::Client,
 }
 
 impl HttpClient {
     pub fn new(config: Settings) -> TuiResult<Self> {
         Ok(Self {
-            scheduler_url: config.scheduler.url,
             client: Client::builder()
                 .timeout(Duration::from_millis(config.http.timeout))
                 .build()?,
+            config,
         })
     }
 
     pub fn scheduler_url(&self) -> &str {
-        &self.scheduler_url
+        &self.config.scheduler.url
     }
 
     pub async fn get_scheduler_state(&self) -> TuiResult<SchedulerState> {
@@ -77,7 +77,7 @@ impl HttpClient {
     }
 
     pub async fn cancel_job(&self, job_id: &str) -> TuiResult<CancelJobResponse> {
-        let url = format!("{}/api/job/{}", self.scheduler_url, job_id);
+        let url = self.url(&format!("job/{}", self.url_encode(job_id)));
         tracing::trace!("Going to PATCH {}", &url);
         let response = self
             .client
@@ -110,7 +110,7 @@ impl HttpClient {
             stage_plan: Option<String>,
         }
 
-        let url = format!("{}/api/job/{}", self.scheduler_url, self.url_encode(job_id));
+        let url = self.url(&format!("job/{}", self.url_encode(job_id)));
         let resp = self.json::<JobDetailResponse>(&url).await?;
         Ok(JobDetails {
             job_id: job_id.to_string(),
@@ -121,20 +121,20 @@ impl HttpClient {
     }
 
     pub async fn get_job_dot(&self, job_id: &str) -> TuiResult<String> {
-        let url = format!(
-            "{}/api/job/{}/dot",
-            self.scheduler_url,
-            self.url_encode(job_id)
-        );
+        let url = self.url(&format!("job/{}/dot", self.url_encode(job_id)));
         self.text(&url).await
     }
 
     pub async fn get_job_stages(&self, job_id: &str) -> TuiResult<JobStagesResponse> {
-        let url = format!(
-            "{}/api/job/{}/stages",
-            self.scheduler_url,
-            self.url_encode(job_id)
-        );
+        let url = self.url(&format!(
+            "job/{}/stages{}",
+            self.url_encode(job_id),
+            if self.config.job.stage.plan.tree {
+                "?render_tree=true"
+            } else {
+                ""
+            }
+        ));
         self.json::<JobStagesResponse>(&url).await
     }
 
@@ -190,7 +190,7 @@ impl HttpClient {
     }
 
     fn url(&self, path: &str) -> String {
-        format!("{}/api/{}", self.scheduler_url, path)
+        format!("{}/api/{}", self.config.scheduler.url, path)
     }
 
     fn url_encode(&self, job_id: &str) -> String {
diff --git a/ballista-cli/src/tui/infrastructure/config.rs b/ballista-cli/src/tui/infrastructure/config.rs
index 323e0e5a3..7cf001c85 100644
--- a/ballista-cli/src/tui/infrastructure/config.rs
+++ b/ballista-cli/src/tui/infrastructure/config.rs
@@ -29,12 +29,32 @@ pub struct HttpSettings {
     pub timeout: u64,
 }
 
+#[derive(Debug, Deserialize)]
+pub struct JobSettings {
+    /// The job stages' settings
+    pub stage: JobStageSettings,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct JobStageSettings {
+    /// The job plan's settings
+    pub plan: JobPlanSettings,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct JobPlanSettings {
+    /// Whether to render the plan as a tree.
+    pub tree: bool,
+}
+
 #[derive(Debug, Deserialize)]
 pub struct Settings {
     pub scheduler: SchedulerSettings,
     pub http: HttpSettings,
     /// How often to refresh the UI. In millis.
     pub tick_interval_ms: u64,
+
+    pub job: JobSettings,
 }
 
 const DEFAULT_CONFIG: &str = r#"
@@ -45,6 +65,11 @@ scheduler:
 
 http:
   timeout: 2000
+
+job:
+  stage:
+    plan:
+      tree: false
 "#;
 
 impl Settings {
diff --git a/ballista-cli/src/tui/mod.rs b/ballista-cli/src/tui/mod.rs
index 415afbc04..eb05ef001 100644
--- a/ballista-cli/src/tui/mod.rs
+++ b/ballista-cli/src/tui/mod.rs
@@ -54,7 +54,7 @@ pub async fn tui_main(tui_mode: Arc<AtomicBool>) -> TuiResult<()> {
 
     let (app_tx, mut app_rx) = tokio::sync::mpsc::channel(16);
     app.set_event_tx(app_tx);
-    let _ = crate::tui::ui::load_executors_data(&app).await;
+    let _ = ui::load_executors_data(&app).await;
 
     loop {
         tui_wrapper.terminal.draw(|f| ui::render(f, &app))?;
diff --git a/ballista-cli/src/tui/ui/footer.rs b/ballista-cli/src/tui/ui/footer.rs
index 615f31cd1..ffcad51ef 100644
--- a/ballista-cli/src/tui/ui/footer.rs
+++ b/ballista-cli/src/tui/ui/footer.rs
@@ -77,9 +77,12 @@ pub(super) fn render_footer(f: &mut Frame, area: Rect, app: &App) {
                             current_view_key_bindings.push(Span::from("[p] View plan, "));
                             current_view_key_bindings
                                 .push(Span::from("[Esc] Close popup, "));
-                        } else if app.is_job_stage_tasks_popup_open()
-                            || app.is_job_stage_plan_popup_open()
-                        {
+                        } else if app.is_job_stage_plan_popup_open() {
+                            current_view_key_bindings
+                                .push(Span::from("[↑↓] Scroll up/down, "));
+                            current_view_key_bindings
+                                .push(Span::from("[Esc] Close popup, "));
+                        } else if app.is_job_stage_tasks_popup_open() {
                             current_view_key_bindings
                                 .push(Span::from("[Esc] Close popup, "));
                         }
diff --git a/ballista-cli/src/tui/ui/main/jobs/mod.rs b/ballista-cli/src/tui/ui/main/jobs/mod.rs
index a6b3955a1..12b951dd3 100644
--- a/ballista-cli/src/tui/ui/main/jobs/mod.rs
+++ b/ballista-cli/src/tui/ui/main/jobs/mod.rs
@@ -46,13 +46,10 @@ use ratatui::{
 };
 
 pub async fn load_jobs_data(app: &App) -> TuiResult<()> {
-    let jobs = match app.http_client.get_jobs().await {
-        Ok(jobs) => jobs,
-        Err(e) => {
-            tracing::error!("Failed to load the jobs: {e:?}");
-            Vec::new()
-        }
-    };
+    let jobs = app.http_client.get_jobs().await.unwrap_or_else(|e| {
+        tracing::error!("Failed to load the jobs: {e:?}");
+        Vec::new()
+    });
 
     app.send_event(Event::DataLoaded {
         data: UiData::Jobs(jobs),
diff --git a/ballista-cli/src/tui/ui/main/jobs/stage_plan_popup.rs b/ballista-cli/src/tui/ui/main/jobs/stage_plan_popup.rs
index c95a32c71..57bd99034 100644
--- a/ballista-cli/src/tui/ui/main/jobs/stage_plan_popup.rs
+++ b/ballista-cli/src/tui/ui/main/jobs/stage_plan_popup.rs
@@ -50,7 +50,9 @@ fn render_plans(f: &mut Frame, area: Rect, app: &App) {
         .border_style(Style::default().fg(Color::LightCyan))
         .border_type(BorderType::Thick);
 
-    let paragraph = Paragraph::new(stage.plan.clone()).block(block);
+    let paragraph = Paragraph::new(&*stage.plan)
+        .block(block)
+        .scroll((popup.plan_scroll_position(), 0));
 
     f.render_widget(paragraph, area);
 }
diff --git a/docs/source/user-guide/cli.md b/docs/source/user-guide/cli.md
index 113601355..3da44fccc 100644
--- a/docs/source/user-guide/cli.md
+++ b/docs/source/user-guide/cli.md
@@ -206,7 +206,14 @@ The TUI provides the following views:
 | `p`       | View execution plan for the selected stage |
 | `Esc`     | Close popup                                |
 
-#### Stage Tasks / Plan Popup Keybindings
+#### Stage Plan Popup Keybindings
+
+| Key       | Action                     |
+| --------- | -------------------------- |
+| `↑` / `↓` | Scroll up/down             |
+| `Esc`     | Return to Job Stages popup |
+
+#### Stage Tasks Popup Keybindings
 
 | Key   | Action                     |
 | ----- | -------------------------- |
@@ -264,6 +271,11 @@ Create the file manually if it does not exist. The following settings are availa
 ```yaml
 tick_interval_ms: 2000
 
+job:
+  stage:
+    plan:
+      tree: false
+
 scheduler:
   url: http://localhost:50050
 
@@ -272,6 +284,7 @@ http:
 ```
 
 - `tick_interval_ms`: How often the TUI refreshes data from the scheduler (milliseconds).
+- `job.stage.plan.tree`: Whether to show the stage execution plan as a tree.
 - `scheduler.url`: The Ballista scheduler HTTP endpoint.
 - `http.timeout`: HTTP request timeout in milliseconds.
 


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