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 083c57285 feat: TUI shows running job information (#1717)
083c57285 is described below
commit 083c57285120e38969c92996a4470354806ddace
Author: Marko Milenković <[email protected]>
AuthorDate: Mon May 18 03:51:32 2026 +0100
feat: TUI shows running job information (#1717)
* feat: TUI shows running job information
* Add unit tests for App::is_selected_job_completed_or_running()
Signed-off-by: Martin Tzvetanov Grigorov <[email protected]>
* Add serde(default) for JobStageResponse::tasks too
Signed-off-by: Martin Tzvetanov Grigorov <[email protected]>
---------
Signed-off-by: Martin Tzvetanov Grigorov <[email protected]>
Co-authored-by: Martin Tzvetanov Grigorov <[email protected]>
---
ballista-cli/src/tui/app.rs | 77 ++++++++++++++++++----
ballista-cli/src/tui/domain/jobs/stages.rs | 15 +++--
ballista-cli/src/tui/ui/footer.rs | 2 +-
.../src/tui/ui/main/jobs/job_stages_popup.rs | 40 ++++++-----
.../src/tui/ui/main/jobs/stage_tasks_popup.rs | 25 ++++---
5 files changed, 117 insertions(+), 42 deletions(-)
diff --git a/ballista-cli/src/tui/app.rs b/ballista-cli/src/tui/app.rs
index 8701ea998..1000afe40 100644
--- a/ballista-cli/src/tui/app.rs
+++ b/ballista-cli/src/tui/app.rs
@@ -542,10 +542,10 @@ impl App {
self.jobs_data.selected_job(&self.search_term).is_some()
}
- pub fn is_selected_job_completed(&self) -> bool {
+ pub fn is_selected_job_completed_or_running(&self) -> bool {
self.jobs_data
.selected_job(&self.search_term)
- .is_some_and(|j| j.status == "Completed")
+ .is_some_and(|j| j.status == "Completed" || j.status == "Running")
}
pub fn is_selected_job_cancelable(&self) -> bool {
@@ -589,11 +589,9 @@ impl App {
}
fn open_job_plan_popup(&mut self) {
- let is_completed = self
- .jobs_data
- .selected_job(&self.search_term)
- .is_some_and(|j| j.status == "Completed");
- if is_completed && let Some(details) = &self.job_details {
+ if self.is_selected_job_completed_or_running()
+ && let Some(details) = &self.job_details
+ {
self.job_plan_popup =
Some(JobPlansPopup::new(details.clone(), PlanTab::Stage));
}
@@ -789,20 +787,45 @@ mod tests {
assert!(app.has_more_than_one_job());
}
+ // --- open_job_plan_popup tests ---
+
+ fn make_job_details(job_id: &str) -> crate::tui::domain::jobs::JobDetails {
+ crate::tui::domain::jobs::JobDetails {
+ job_id: job_id.to_string(),
+ logical_plan: Some("logical".to_string()),
+ physical_plan: Some("physical".to_string()),
+ stage_plan: Some("stage".to_string()),
+ }
+ }
+
+ #[test]
+ fn open_job_plan_popup_opens_for_running_job_with_details() {
+ let mut app = make_app();
+ app.jobs_data.jobs = vec![make_job("j1", "Running")];
+ app.jobs_data.table_state.select(Some(0));
+ app.job_details = Some(make_job_details("j1"));
+ app.open_job_plan_popup();
+ assert!(app.job_plan_popup.is_some());
+ }
+
#[test]
- fn is_selected_job_completed_true_when_completed_job_selected() {
+ fn open_job_plan_popup_opens_for_completed_job_with_details() {
let mut app = make_app();
app.jobs_data.jobs = vec![make_job("j1", "Completed")];
app.jobs_data.table_state.select(Some(0));
- assert!(app.is_selected_job_completed());
+ app.job_details = Some(make_job_details("j1"));
+ app.open_job_plan_popup();
+ assert!(app.job_plan_popup.is_some());
}
#[test]
- fn is_selected_job_completed_false_for_running_job() {
+ fn open_job_plan_popup_does_not_open_without_details() {
let mut app = make_app();
app.jobs_data.jobs = vec![make_job("j1", "Running")];
app.jobs_data.table_state.select(Some(0));
- assert!(!app.is_selected_job_completed());
+ app.job_details = None;
+ app.open_job_plan_popup();
+ assert!(app.job_plan_popup.is_none());
}
// --- sort_jobs_by toggle tests ---
@@ -1057,4 +1080,36 @@ mod tests {
app.jobs_data.table_state.select(Some(0));
assert!(!app.is_selected_job_cancelable());
}
+
+ #[test]
+ fn is_selected_job_completed_or_running_for_completed() {
+ let mut app = make_app();
+ app.jobs_data.jobs = vec![make_job("j1", "Completed")];
+ app.jobs_data.table_state.select(Some(0));
+ assert!(app.is_selected_job_completed_or_running());
+ }
+
+ #[test]
+ fn is_selected_job_completed_or_running_for_running() {
+ let mut app = make_app();
+ app.jobs_data.jobs = vec![make_job("j1", "Running")];
+ app.jobs_data.table_state.select(Some(0));
+ assert!(app.is_selected_job_completed_or_running());
+ }
+
+ #[test]
+ fn is_selected_job_completed_or_running_for_queued() {
+ let mut app = make_app();
+ app.jobs_data.jobs = vec![make_job("j1", "Queued")];
+ app.jobs_data.table_state.select(Some(0));
+ assert!(!app.is_selected_job_completed_or_running());
+ }
+
+ #[test]
+ fn is_selected_job_completed_or_running_for_failed() {
+ let mut app = make_app();
+ app.jobs_data.jobs = vec![make_job("j1", "Failed")];
+ app.jobs_data.table_state.select(Some(0));
+ assert!(!app.is_selected_job_completed_or_running());
+ }
}
diff --git a/ballista-cli/src/tui/domain/jobs/stages.rs
b/ballista-cli/src/tui/domain/jobs/stages.rs
index b6aea823a..824217471 100644
--- a/ballista-cli/src/tui/domain/jobs/stages.rs
+++ b/ballista-cli/src/tui/domain/jobs/stages.rs
@@ -29,14 +29,17 @@ pub struct JobStageResponse {
pub id: String,
#[serde(rename = "stage_status")]
pub status: String,
- #[serde(rename = "stage_plan")]
+ #[serde(rename = "stage_plan", default)]
pub plan: String,
pub input_rows: usize,
pub output_rows: usize,
pub elapsed_compute: Option<String>,
- pub task_duration_percentiles: TaskPercentiles,
- pub task_input_percentiles: TaskPercentiles,
- pub tasks: Vec<StageTaskResponse>,
+ #[serde(default)]
+ pub task_duration_percentiles: Option<TaskPercentiles>,
+ #[serde(default)]
+ pub task_input_percentiles: Option<TaskPercentiles>,
+ #[serde(default)]
+ pub tasks: Vec<Option<StageTaskResponse>>,
}
// TaskSummary
@@ -246,8 +249,8 @@ mod tests {
input_rows: 0,
output_rows: 0,
elapsed_compute: Some("1ns".to_string()),
- task_duration_percentiles: make_percentiles(),
- task_input_percentiles: make_percentiles(),
+ task_duration_percentiles: Some(make_percentiles()),
+ task_input_percentiles: Some(make_percentiles()),
tasks: Vec::new(),
}
}
diff --git a/ballista-cli/src/tui/ui/footer.rs
b/ballista-cli/src/tui/ui/footer.rs
index ffcad51ef..27a65073f 100644
--- a/ballista-cli/src/tui/ui/footer.rs
+++ b/ballista-cli/src/tui/ui/footer.rs
@@ -66,7 +66,7 @@ pub(super) fn render_footer(f: &mut Frame, area: Rect, app:
&App) {
.push(Span::from("[c] Cancel job, "));
}
- if app.is_selected_job_completed() {
+ if app.is_selected_job_completed_or_running() {
current_view_key_bindings
.push(Span::from("[p] View job plans, "));
}
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 874b24e9f..f0b641a13 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
@@ -97,23 +97,31 @@ fn build_stage_row(i: usize, stage: &JobStageResponse, app:
&App) -> Row<'static
_ => Color::Gray,
};
- let p = &stage.task_duration_percentiles;
- let duration_percentiles = format!(
- "{}/{}/{}/{}/{}",
- app.format_duration(p.min),
- app.format_duration(p.p25),
- app.format_duration(p.median),
- app.format_duration(p.p75),
- app.format_duration(p.max)
+ let duration_percentiles =
stage.task_duration_percentiles.as_ref().map_or_else(
+ || "N/A".to_string(),
+ |p| {
+ format!(
+ "{}/{}/{}/{}/{}",
+ app.format_duration(p.min),
+ app.format_duration(p.p25),
+ app.format_duration(p.median),
+ app.format_duration(p.p75),
+ app.format_duration(p.max)
+ )
+ },
);
- let p = &stage.task_input_percentiles;
- let input_percentiles = format!(
- "{}/{}/{}/{}/{}",
- app.format_count(p.min.try_into().unwrap_or(0)),
- app.format_count(p.p25.try_into().unwrap_or(0)),
- app.format_count(p.median.try_into().unwrap_or(0)),
- app.format_count(p.p75.try_into().unwrap_or(0)),
- app.format_count(p.max.try_into().unwrap_or(0))
+ let input_percentiles = stage.task_input_percentiles.as_ref().map_or_else(
+ || "N/A".to_string(),
+ |p| {
+ format!(
+ "{}/{}/{}/{}/{}",
+ app.format_count(p.min.try_into().unwrap_or(0)),
+ app.format_count(p.p25.try_into().unwrap_or(0)),
+ app.format_count(p.median.try_into().unwrap_or(0)),
+ app.format_count(p.p75.try_into().unwrap_or(0)),
+ app.format_count(p.max.try_into().unwrap_or(0))
+ )
+ },
);
Row::new(vec![
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 c04bf45a7..7b371da91 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
@@ -60,6 +60,7 @@ pub(crate) fn render_stage_tasks_popup(f: &mut Frame, app:
&App) {
let rows = stage
.tasks
.iter()
+ .flatten()
.enumerate()
.map(|(i, task)| build_stage_task_row(i, task, app));
@@ -121,20 +122,28 @@ fn build_stage_task_row(i: usize, task:
&StageTaskResponse, app: &App) -> Row<'s
Cell::from(Text::from(task.partition_id.to_string()).right_aligned()),
Cell::from(Text::from(format_datetime(task.scheduled_time,
app)).centered()),
Cell::from(
- Text::from(app.format_duration(task.launch_time -
task.scheduled_time))
- .right_aligned(),
+ Text::from(
+
app.format_duration(task.launch_time.saturating_sub(task.scheduled_time)),
+ )
+ .right_aligned(),
),
Cell::from(
- Text::from(app.format_duration(task.start_exec_time -
task.scheduled_time))
- .right_aligned(),
+ Text::from(app.format_duration(
+ task.start_exec_time.saturating_sub(task.scheduled_time),
+ ))
+ .right_aligned(),
),
Cell::from(
- Text::from(app.format_duration(task.end_exec_time -
task.start_exec_time))
- .right_aligned(),
+ Text::from(app.format_duration(
+ task.end_exec_time.saturating_sub(task.start_exec_time),
+ ))
+ .right_aligned(),
),
Cell::from(
- Text::from(app.format_duration(task.finish_time -
task.scheduled_time))
- .right_aligned(),
+ Text::from(
+
app.format_duration(task.finish_time.saturating_sub(task.scheduled_time)),
+ )
+ .right_aligned(),
),
])
.style(Style::default().bg(bg))
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]