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 d49a530b7 feat: TUI make task popup scrollable (#1725)
d49a530b7 is described below
commit d49a530b7df1f2c6da706123490f2c22a10fe5d6
Author: Marko Milenković <[email protected]>
AuthorDate: Tue May 19 09:12:28 2026 +0100
feat: TUI make task popup scrollable (#1725)
* feat: task popup scrollable
* add stages popup scroll bar
* Extract a helper function for counting the stage's tasks
* ScrollbarState::new() sets position to 0
* Select first task only if there is at least one task
* Do not try to scroll up if there are no tasks
* Scroll up/down in tasks popup wraps on overflow
As the other tables do
* Add unit tests for the scroll support in stages and tasks
AI generated
* Add more unit tests for App methods
AI generated
* Make datetime related tests timezone independant
---------
Co-authored-by: Martin Tzvetanov Grigorov <[email protected]>
---
ballista-cli/src/tui/app.rs | 131 +++++++++-
ballista-cli/src/tui/domain/jobs/stages.rs | 264 ++++++++++++++++++++-
.../src/tui/ui/main/jobs/job_stages_popup.rs | 2 +
.../src/tui/ui/main/jobs/stage_tasks_popup.rs | 5 +-
4 files changed, 393 insertions(+), 9 deletions(-)
diff --git a/ballista-cli/src/tui/app.rs b/ballista-cli/src/tui/app.rs
index 1000afe40..7bddf86b5 100644
--- a/ballista-cli/src/tui/app.rs
+++ b/ballista-cli/src/tui/app.rs
@@ -36,6 +36,7 @@ use crate::tui::{
};
use chrono::DateTime;
use crossterm::event::{KeyCode, KeyEvent};
+use std::string::ToString;
use std::sync::Arc;
use tokio::sync::mpsc::Sender;
@@ -45,6 +46,8 @@ use crate::tui::ui::{
load_job_stages_popup, load_jobs_data, load_metrics_data,
};
+const INVALID_DATE: &str = "Invalid date";
+
#[derive(Debug, PartialEq)]
enum Views {
Executors,
@@ -197,10 +200,13 @@ impl App {
}
if let Some(popup) = &mut self.job_stages_popup {
- if popup.is_tasks_view()
- && let KeyCode::Esc = key.code
- {
- popup.set_no_details_view();
+ 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(),
@@ -604,7 +610,7 @@ impl App {
.format("%Y-%m-%d %H:%M:%S")
.to_string()
})
- .unwrap_or_else(|| "Invalid date".to_string())
+ .unwrap_or_else(|| INVALID_DATE.to_string())
}
// copied from DataFusion Commons to avoid depending on it
@@ -688,7 +694,9 @@ impl App {
mod tests {
use crate::tui::App;
use crate::tui::Settings;
- use crate::tui::app::{ExecutorsSortColumn, JobsSortColumn,
MetricsSortColumn};
+ use crate::tui::app::{
+ ExecutorsSortColumn, INVALID_DATE, JobsSortColumn, MetricsSortColumn,
+ };
use crate::tui::domain::{
SchedulerState, SortOrder,
executors::{Executor, ExecutorDetailsPopup, OsInfo, Specification},
@@ -1112,4 +1120,115 @@ mod tests {
app.jobs_data.table_state.select(Some(0));
assert!(!app.is_selected_job_completed_or_running());
}
+
+ // --- has_selected_job with selection ---
+
+ #[test]
+ fn has_selected_job_true_when_selected() {
+ let mut app = make_app();
+ app.jobs_data.jobs = vec![make_job("j1", "Running")];
+ app.jobs_data.table_state.select(Some(0));
+ assert!(app.has_selected_job());
+ }
+
+ // --- format_datetime tests ---
+
+ #[test]
+ fn format_datetime_zero_timestamp_is_valid_format() {
+ let app = make_app();
+ let result = app.format_datetime(0);
+ assert_ne!(result, INVALID_DATE);
+ }
+
+ #[test]
+ fn format_datetime_known_timestamp_contains_year() {
+ // 1_000_000_000_000 ms = 2001-09-09T01:46:40 UTC
+ let app = make_app();
+ let result = app.format_datetime(1_000_000_000_000);
+ assert!(result.contains("2001"), "{result} must contain year 2001");
+ }
+
+ #[test]
+ fn format_datetime_out_of_range_returns_invalid() {
+ let app = make_app();
+ assert_eq!(app.format_datetime(i64::MAX), "Invalid date");
+ }
+
+ // --- format_duration tests ---
+
+ #[test]
+ fn format_duration_zero_ms_returns_nanoseconds() {
+ let app = make_app();
+ assert_eq!(app.format_duration(0), "0ns");
+ }
+
+ #[test]
+ fn format_duration_one_ms_returns_milliseconds() {
+ let app = make_app();
+ assert_eq!(app.format_duration(1), "1.00ms");
+ }
+
+ #[test]
+ fn format_duration_one_second() {
+ let app = make_app();
+ assert_eq!(app.format_duration(1_000), "1.00s");
+ }
+
+ #[test]
+ fn format_duration_large_value_returns_seconds() {
+ let app = make_app();
+ assert_eq!(app.format_duration(90_000), "90.00s");
+ }
+
+ // --- format_count tests ---
+
+ #[test]
+ fn format_count_zero() {
+ let app = make_app();
+ assert_eq!(app.format_count(0), "0");
+ }
+
+ #[test]
+ fn format_count_below_thousand_returns_raw() {
+ let app = make_app();
+ assert_eq!(app.format_count(999), "999");
+ }
+
+ #[test]
+ fn format_count_thousands_two_decimals() {
+ let app = make_app();
+ assert_eq!(app.format_count(1_000), "1.00K");
+ }
+
+ #[test]
+ fn format_count_thousands_large_one_decimal() {
+ let app = make_app();
+ assert_eq!(app.format_count(100_000), "100.0K");
+ }
+
+ #[test]
+ fn format_count_millions() {
+ let app = make_app();
+ assert_eq!(app.format_count(1_000_000), "1.00M");
+ }
+
+ #[test]
+ fn format_count_billions() {
+ let app = make_app();
+ assert_eq!(app.format_count(1_000_000_000), "1.00B");
+ }
+
+ #[test]
+ fn format_count_trillions() {
+ let app = make_app();
+ assert_eq!(app.format_count(1_000_000_000_000), "1.00T");
+ }
+
+ // --- format_size additional boundary ---
+
+ #[test]
+ fn format_size_terabytes() {
+ let app = make_app();
+ assert_eq!(app.format_size(2 * 1024 * 1024 * 1024 * 1024), "2.0TB");
+ }
}
diff --git a/ballista-cli/src/tui/domain/jobs/stages.rs
b/ballista-cli/src/tui/domain/jobs/stages.rs
index 824217471..6cdacabfe 100644
--- a/ballista-cli/src/tui/domain/jobs/stages.rs
+++ b/ballista-cli/src/tui/domain/jobs/stages.rs
@@ -15,7 +15,7 @@
// specific language governing permissions and limitations
// under the License.
-use ratatui::widgets::TableState;
+use ratatui::widgets::{ScrollbarState, TableState};
use serde::Deserialize;
#[derive(Deserialize, Clone, Debug)]
@@ -79,6 +79,9 @@ pub struct JobStagesPopup {
pub job_id: String,
pub stages: JobStagesResponse,
pub table_state: TableState,
+ pub scrollbar_state: ScrollbarState,
+ pub tasks_table_state: TableState,
+ pub tasks_scrollbar_state: ScrollbarState,
details_view: StageDetailsView,
plan_vertical_scroll_position: u16,
plan_horizontal_scroll_position: u16,
@@ -88,8 +91,11 @@ impl JobStagesPopup {
pub fn new(job_id: String, stages: JobStagesResponse) -> Self {
Self {
job_id,
+ scrollbar_state: ScrollbarState::new(stages.stages.len()),
stages,
table_state: TableState::default(),
+ tasks_table_state: TableState::default(),
+ tasks_scrollbar_state: ScrollbarState::new(0),
details_view: StageDetailsView::None,
plan_vertical_scroll_position: 0,
plan_horizontal_scroll_position: 0,
@@ -106,6 +112,8 @@ impl JobStagesPopup {
pub fn set_tasks_view(&mut self) {
self.details_view = StageDetailsView::Tasks;
+ self.tasks_table_state = TableState::default().with_selected(None);
+ self.tasks_scrollbar_state = ScrollbarState::new(self.tasks_count());
}
pub fn set_plan_view(&mut self) {
@@ -140,12 +148,17 @@ impl JobStagesPopup {
if let Some(selected) = self.table_state.selected() {
if selected < len - 1 {
self.table_state.select(Some(selected + 1));
+ self.scrollbar_state =
self.scrollbar_state.position(selected + 1);
} else {
self.table_state.select(None);
+ self.scrollbar_state = self.scrollbar_state.position(0);
}
} else {
self.table_state.select(Some(0));
+ self.scrollbar_state = self.scrollbar_state.position(0);
}
+ } else if self.is_tasks_view() {
+ self.tasks_scroll_down();
} else if self.is_plan_view() {
self.plan_vertical_scroll_position =
self.plan_vertical_scroll_position.saturating_add(1);
@@ -162,18 +175,53 @@ impl JobStagesPopup {
if let Some(selected) = self.table_state.selected() {
if selected == 0 {
self.table_state.select(None);
+ self.scrollbar_state = self.scrollbar_state.position(0);
} else {
self.table_state.select(Some(selected - 1));
+ self.scrollbar_state =
self.scrollbar_state.position(selected - 1);
}
} else {
self.table_state.select(Some(len - 1));
+ self.scrollbar_state = self.scrollbar_state.position(len - 1);
}
+ } else if self.is_tasks_view() {
+ self.tasks_scroll_up();
} else if self.is_plan_view() {
self.plan_vertical_scroll_position =
self.plan_vertical_scroll_position.saturating_sub(1);
}
}
+ fn tasks_scroll_down(&mut self) {
+ let tasks_count = self.tasks_count();
+ if tasks_count == 0 {
+ return;
+ }
+ let next = match self.tasks_table_state.selected() {
+ Some(i) if i < tasks_count - 1 => Some(i + 1),
+ Some(_) => None,
+ None => Some(0),
+ };
+ self.tasks_table_state.select(next);
+ self.tasks_scrollbar_state =
+ self.tasks_scrollbar_state.position(next.unwrap_or(0));
+ }
+
+ fn tasks_scroll_up(&mut self) {
+ let tasks_count = self.tasks_count();
+ if tasks_count == 0 {
+ return;
+ }
+ let prev = match self.tasks_table_state.selected() {
+ Some(i) if i > 0 => Some(i - 1),
+ Some(_) => None,
+ None => Some(tasks_count - 1),
+ };
+ self.tasks_table_state.select(prev);
+ self.tasks_scrollbar_state =
+ self.tasks_scrollbar_state.position(prev.unwrap_or(0));
+ }
+
pub fn scroll_left(&mut self) {
if self.is_plan_view() {
self.plan_horizontal_scroll_position =
@@ -193,6 +241,12 @@ impl JobStagesPopup {
.selected()
.and_then(|i| self.stages.stages.get(i))
}
+
+ fn tasks_count(&self) -> usize {
+ self.selected_stage()
+ .map(|s| s.tasks.iter().flatten().count())
+ .unwrap_or(0)
+ }
}
#[derive(Clone, Debug)]
@@ -228,7 +282,8 @@ impl StagesGraph {
#[cfg(test)]
mod tests {
use super::{
- JobStageResponse, JobStagesPopup, JobStagesResponse, StagesGraph,
TaskPercentiles,
+ JobStageResponse, JobStagesPopup, JobStagesResponse, StageTaskResponse,
+ StagesGraph, TaskPercentiles,
};
fn make_percentiles() -> TaskPercentiles {
@@ -466,4 +521,209 @@ mod tests {
graph.scroll_up();
assert_eq!(graph.scroll_position, 0);
}
+
+ // --- Helpers for task-bearing stages ---
+
+ fn make_task(id: usize) -> StageTaskResponse {
+ StageTaskResponse {
+ id,
+ status: "Completed".to_string(),
+ partition_id: id as u32,
+ input_rows: 0,
+ output_rows: 0,
+ scheduled_time: 0,
+ launch_time: 0,
+ start_exec_time: 0,
+ end_exec_time: 0,
+ finish_time: 0,
+ }
+ }
+
+ fn make_stage_with_tasks(id: &str, task_count: usize) -> JobStageResponse {
+ let mut stage = make_stage(id);
+ stage.tasks = (0..task_count).map(|i| Some(make_task(i))).collect();
+ stage
+ }
+
+ // Creates a popup with one stage that has `task_count` tasks; the stage
is pre-selected.
+ fn make_popup_with_tasks(task_count: usize) -> JobStagesPopup {
+ let stage = make_stage_with_tasks("0", task_count);
+ let mut popup = JobStagesPopup::new(
+ "job1".to_string(),
+ JobStagesResponse {
+ stages: vec![stage],
+ },
+ );
+ popup.table_state.select(Some(0));
+ popup
+ }
+
+ // --- set_tasks_view ---
+
+ #[test]
+ fn set_tasks_view_with_tasks_does_not_preselect() {
+ let mut popup = make_popup_with_tasks(3);
+ popup.set_tasks_view();
+ assert_eq!(popup.tasks_table_state.selected(), None);
+ }
+
+ #[test]
+ fn set_tasks_view_with_no_tasks_selects_none() {
+ let mut popup = make_popup_with_tasks(0);
+ popup.set_tasks_view();
+ assert_eq!(popup.tasks_table_state.selected(), None);
+ }
+
+ // --- tasks_scroll_down (via scroll_down in tasks view) ---
+
+ #[test]
+ fn scroll_down_in_tasks_view_advances_selection() {
+ let mut popup = make_popup_with_tasks(3);
+ popup.set_tasks_view();
+ // set_tasks_view does not pre-select a task; scrolling down should
move to 0
+ popup.scroll_down();
+ assert_eq!(popup.tasks_table_state.selected(), Some(0));
+ }
+
+ #[test]
+ fn scroll_down_in_tasks_view_at_last_deselects() {
+ let mut popup = make_popup_with_tasks(3);
+ popup.set_tasks_view();
+ popup.tasks_table_state.select(Some(2));
+ popup.scroll_down();
+ assert_eq!(popup.tasks_table_state.selected(), None);
+ }
+
+ #[test]
+ fn scroll_down_in_tasks_view_from_none_selects_first() {
+ let mut popup = make_popup_with_tasks(3);
+ popup.set_tasks_view();
+ popup.tasks_table_state.select(None);
+ popup.scroll_down();
+ assert_eq!(popup.tasks_table_state.selected(), Some(0));
+ }
+
+ #[test]
+ fn scroll_down_in_tasks_view_empty_tasks_does_nothing() {
+ let mut popup = make_popup_with_tasks(0);
+ popup.set_tasks_view();
+ popup.scroll_down();
+ assert_eq!(popup.tasks_table_state.selected(), None);
+ }
+
+ // --- tasks_scroll_up (via scroll_up in tasks view) ---
+
+ #[test]
+ fn scroll_up_in_tasks_view_moves_back() {
+ let mut popup = make_popup_with_tasks(3);
+ popup.set_tasks_view();
+ popup.tasks_table_state.select(Some(2));
+ popup.scroll_up();
+ assert_eq!(popup.tasks_table_state.selected(), Some(1));
+ }
+
+ #[test]
+ fn scroll_up_in_tasks_view_at_first_selects_last() {
+ let mut popup = make_popup_with_tasks(3);
+ popup.set_tasks_view();
+ // tasks_table_state is already at None after set_tasks_view
+ popup.scroll_up();
+ assert_eq!(popup.tasks_table_state.selected(), Some(2));
+ }
+
+ #[test]
+ fn scroll_up_in_tasks_view_from_none_selects_last() {
+ let mut popup = make_popup_with_tasks(3);
+ popup.set_tasks_view();
+ popup.tasks_table_state.select(None);
+ popup.scroll_up();
+ assert_eq!(popup.tasks_table_state.selected(), Some(2));
+ }
+
+ #[test]
+ fn scroll_up_in_tasks_view_empty_tasks_does_nothing() {
+ let mut popup = make_popup_with_tasks(0);
+ popup.set_tasks_view();
+ popup.scroll_up();
+ assert_eq!(popup.tasks_table_state.selected(), None);
+ }
+
+ // --- Plan view scrolling ---
+
+ #[test]
+ fn set_plan_view_resets_scroll_positions() {
+ let mut popup = make_popup(2);
+ popup.set_plan_view();
+ popup.scroll_right();
+ popup.scroll_down();
+ popup.set_plan_view();
+ assert_eq!(popup.plan_horizontal_scroll_position(), 0);
+ assert_eq!(popup.plan_vertical_scroll_position(), 0);
+ }
+
+ #[test]
+ fn scroll_down_in_plan_view_increments_vertical() {
+ let mut popup = make_popup(2);
+ popup.set_plan_view();
+ popup.scroll_down();
+ assert_eq!(popup.plan_vertical_scroll_position(), 1);
+ }
+
+ #[test]
+ fn scroll_up_in_plan_view_decrements_vertical() {
+ let mut popup = make_popup(2);
+ popup.set_plan_view();
+ popup.scroll_down();
+ popup.scroll_up();
+ assert_eq!(popup.plan_vertical_scroll_position(), 0);
+ }
+
+ #[test]
+ fn scroll_up_in_plan_view_saturates_at_zero() {
+ let mut popup = make_popup(2);
+ popup.set_plan_view();
+ popup.scroll_up();
+ assert_eq!(popup.plan_vertical_scroll_position(), 0);
+ }
+
+ #[test]
+ fn scroll_right_increments_horizontal() {
+ let mut popup = make_popup(2);
+ popup.set_plan_view();
+ popup.scroll_right();
+ assert_eq!(popup.plan_horizontal_scroll_position(), 1);
+ }
+
+ #[test]
+ fn scroll_left_decrements_horizontal() {
+ let mut popup = make_popup(2);
+ popup.set_plan_view();
+ popup.scroll_right();
+ popup.scroll_left();
+ assert_eq!(popup.plan_horizontal_scroll_position(), 0);
+ }
+
+ #[test]
+ fn scroll_left_saturates_at_zero() {
+ let mut popup = make_popup(2);
+ popup.set_plan_view();
+ popup.scroll_left();
+ assert_eq!(popup.plan_horizontal_scroll_position(), 0);
+ }
+
+ // --- scroll_left / scroll_right no-ops outside plan view ---
+
+ #[test]
+ fn scroll_left_in_no_details_view_does_nothing() {
+ let mut popup = make_popup(2);
+ popup.scroll_left();
+ assert_eq!(popup.plan_horizontal_scroll_position(), 0);
+ }
+
+ #[test]
+ fn scroll_right_in_no_details_view_does_nothing() {
+ let mut popup = make_popup(2);
+ popup.scroll_right();
+ assert_eq!(popup.plan_horizontal_scroll_position(), 0);
+ }
}
diff --git a/ballista-cli/src/tui/ui/main/jobs/job_stages_popup.rs
b/ballista-cli/src/tui/ui/main/jobs/job_stages_popup.rs
index f0b641a13..3e9631a7f 100644
--- a/ballista-cli/src/tui/ui/main/jobs/job_stages_popup.rs
+++ b/ballista-cli/src/tui/ui/main/jobs/job_stages_popup.rs
@@ -80,7 +80,9 @@ pub(crate) fn render_job_stages_popup(f: &mut Frame, app:
&App) {
.highlight_spacing(HighlightSpacing::Always);
let mut table_state = popup.table_state;
+ let mut scroll_state = popup.scrollbar_state;
f.render_stateful_widget(table, area, &mut table_state);
+ crate::tui::ui::vertical_scrollbar::render_scrollbar(f, area, &mut
scroll_state);
}
fn build_stage_row(i: usize, stage: &JobStageResponse, app: &App) ->
Row<'static> {
diff --git a/ballista-cli/src/tui/ui/main/jobs/stage_tasks_popup.rs
b/ballista-cli/src/tui/ui/main/jobs/stage_tasks_popup.rs
index 7b371da91..d012fb0e7 100644
--- a/ballista-cli/src/tui/ui/main/jobs/stage_tasks_popup.rs
+++ b/ballista-cli/src/tui/ui/main/jobs/stage_tasks_popup.rs
@@ -93,7 +93,10 @@ pub(crate) fn render_stage_tasks_popup(f: &mut Frame, app:
&App) {
.row_highlight_style(Style::default().bg(Color::Indexed(29)))
.highlight_spacing(HighlightSpacing::Always);
- f.render_widget(table, area);
+ let mut table_state = popup.tasks_table_state;
+ let mut scroll_state = popup.tasks_scrollbar_state;
+ f.render_stateful_widget(table, area, &mut table_state);
+ crate::tui::ui::vertical_scrollbar::render_scrollbar(f, area, &mut
scroll_state);
}
fn build_stage_task_row(i: usize, task: &StageTaskResponse, app: &App) ->
Row<'static> {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]