diff --git a/.cursor/rules.md b/.cursor/rules.md new file mode 100644 index 0000000000..00385e6f5f --- /dev/null +++ b/.cursor/rules.md @@ -0,0 +1,5 @@ +The AI agents should never link to any issue or a pull request +in any GitHub repository in the code reviews! + +The AI agents should not review AI agents' config files like CLAUDE.md or AGENTS.md! + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..00385e6f5f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +The AI agents should never link to any issue or a pull request +in any GitHub repository in the code reviews! + +The AI agents should not review AI agents' config files like CLAUDE.md or AGENTS.md! + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..00385e6f5f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +The AI agents should never link to any issue or a pull request +in any GitHub repository in the code reviews! + +The AI agents should not review AI agents' config files like CLAUDE.md or AGENTS.md! + diff --git a/ballista-cli/src/tui/app.rs b/ballista-cli/src/tui/app.rs index f8dad693c9..c3ae4825c2 100644 --- a/ballista-cli/src/tui/app.rs +++ b/ballista-cli/src/tui/app.rs @@ -18,6 +18,8 @@ #[cfg(not(feature = "web"))] use crate::tui::TuiError; use crate::tui::TuiResult; +#[cfg(feature = "web")] +use crate::tui::domain::jobs::stages::StagePlanTab; use crate::tui::event::Event; #[cfg(feature = "web")] use crate::tui::event::web::Sender; @@ -28,8 +30,8 @@ use crate::tui::{ ExecutorDetailsPopup, ExecutorsData, SortColumn as ExecutorsSortColumn, }, jobs::{ - CancelJobResult, JobDetails, JobPlansPopup, JobsData, PlanTab, - SortColumn as JobsSortColumn, + CancelJobResult, JobDetails, JobPlansPopup, JobsData, PhysicalFormat, + PlanTab, SortColumn as JobsSortColumn, stages::{JobStagesPopup, StagesGraph}, }, metrics::MetricsData, @@ -52,7 +54,7 @@ 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, + load_job_stages_popup, load_jobs_data, load_metrics_data, load_stage_plan, }; const INVALID_DATE: &str = "Invalid date"; @@ -221,13 +223,47 @@ impl App { _ => {} } } 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(), - _ => {} + use crate::tui::domain::jobs::stages::StagePlanTab; + + // Collect job_id + tab to fetch while popup is still borrowed, + // then drop the borrow before the async call. + let fetch = match key.code { + KeyCode::Char('d') => popup + .set_tab(StagePlanTab::Default) + .map(|tab| (popup.job_id.clone(), tab)), + KeyCode::Char('t') => popup + .set_tab(StagePlanTab::Tree) + .map(|tab| (popup.job_id.clone(), tab)), + KeyCode::Char('m') => popup + .set_tab(StagePlanTab::Metrics) + .map(|tab| (popup.job_id.clone(), tab)), + KeyCode::Esc => { + popup.set_no_details_view(); + None + } + KeyCode::Up => { + popup.scroll_up(); + None + } + KeyCode::Down => { + popup.scroll_down(); + None + } + KeyCode::Left => { + popup.scroll_left(); + None + } + KeyCode::Right => { + popup.scroll_right(); + None + } + _ => None, + }; + + if let Some((job_id, tab)) = fetch + && let Err(e) = load_stage_plan(self, &job_id, tab).await + { + tracing::error!("Failed to load stage plan: {e:?}"); } } else if popup.is_no_details_view() { match key.code { @@ -265,25 +301,55 @@ impl App { } 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); + let fetch_tree = if key.code == KeyCode::Char('t') + && plans_popup.get_tab() == &PlanTab::Physical + { + plans_popup + .set_physical_format(PhysicalFormat::Tree) + .map(|_| plans_popup.details.job_id.clone()) + } else { + 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::Char('d') if plans_popup.get_tab() == &PlanTab::Physical => { + plans_popup.set_physical_format(PhysicalFormat::Default); + } + KeyCode::Esc => { + self.job_plan_popup = None; + } + _ => {} } - KeyCode::Esc => { - self.job_plan_popup = None; + None + }; + + if let Some(job_id) = fetch_tree { + match self + .http_client + .get_job_details(&job_id, Some("tree")) + .await + { + Ok(details) => { + if let Some(p) = &mut self.job_plan_popup { + p.details.physical_plan_tree = details.physical_plan; + } + } + Err(e) => tracing::error!("Failed to load tree physical plan: {e:?}"), } - _ => {} } + return Ok(()); } @@ -789,7 +855,13 @@ impl App { }; } UiData::JobDetails(details) => { - self.job_details = Some(details); + if details.physical_plan_tree.is_some() { + if let Some(popup) = &mut self.job_plan_popup { + popup.details.physical_plan_tree = details.physical_plan_tree; + } + } else { + self.job_details = Some(details); + } } UiData::JobStagesGraph(graph) => { self.job_dot_popup = Some(graph); @@ -797,6 +869,11 @@ impl App { UiData::JobStagesData(job_id, stages) => { self.job_stages_popup = Some(JobStagesPopup::new(job_id, stages)); } + UiData::JobStagesPlanData(tab, stages) => { + if let Some(popup) = &mut self.job_stages_popup { + popup.cache_plan_response(tab, stages); + } + } UiData::ExecutorDetails(executor) => { self.executor_details_popup = Some(ExecutorDetailsPopup::new(executor)); } @@ -841,14 +918,43 @@ impl App { _ => {} } } 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(), - _ => {} - } + use crate::tui::domain::jobs::stages::StagePlanTab; + + let fetch = match key.code { + KeyCode::Char('d') => popup + .set_tab(StagePlanTab::Default) + .map(|tab| (popup.job_id.clone(), tab)), + KeyCode::Char('t') => popup + .set_tab(StagePlanTab::Tree) + .map(|tab| (popup.job_id.clone(), tab)), + KeyCode::Char('m') => popup + .set_tab(StagePlanTab::Metrics) + .map(|tab| (popup.job_id.clone(), tab)), + KeyCode::Esc => { + popup.set_no_details_view(); + None + } + KeyCode::Up => { + popup.scroll_up(); + None + } + KeyCode::Down => { + popup.scroll_down(); + None + } + KeyCode::Left => { + popup.scroll_left(); + None + } + KeyCode::Right => { + popup.scroll_right(); + None + } + _ => None, + }; + + return fetch + .map(|(job_id, tab)| WebKeyAsyncAction::LoadStagePlan(job_id, tab)); } else if popup.is_no_details_view() { match key.code { KeyCode::Up => popup.scroll_up(), @@ -879,18 +985,39 @@ impl App { } 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; + let fetch_tree = if key.code == KeyCode::Char('t') + && plans_popup.get_tab() == &PlanTab::Physical + { + plans_popup + .set_physical_format(PhysicalFormat::Tree) + .map(|_| plans_popup.details.job_id.clone()) + } else { + 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::Char('d') if plans_popup.get_tab() == &PlanTab::Physical => { + plans_popup.set_physical_format(PhysicalFormat::Default); + } + KeyCode::Esc => self.job_plan_popup = None, + _ => {} + } + None + }; + + return fetch_tree.map(WebKeyAsyncAction::LoadJobPlanTree); } if let Some(ref mut executor_popup) = self.executor_details_popup { @@ -1084,6 +1211,8 @@ pub enum WebKeyAsyncAction { CancelJob(String), UpdateJobDetails(Option), ReloadView, + LoadJobPlanTree(String), + LoadStagePlan(String, StagePlanTab), } #[cfg(test)] @@ -1199,6 +1328,7 @@ mod tests { job_id: job_id.to_string(), logical_plan: Some("logical".to_string()), physical_plan: Some("physical".to_string()), + physical_plan_tree: Some("tree".to_string()), stage_plan: Some("stage".to_string()), } } diff --git a/ballista-cli/src/tui/domain/jobs.rs b/ballista-cli/src/tui/domain/jobs.rs index df4e89cffd..fa89ecd633 100644 --- a/ballista-cli/src/tui/domain/jobs.rs +++ b/ballista-cli/src/tui/domain/jobs.rs @@ -219,11 +219,18 @@ pub enum CancelJobResult { Failure { job_id: String, error: String }, } +#[derive(Debug, Clone, PartialEq)] +pub enum PhysicalFormat { + Default, + Tree, +} + #[derive(Clone, Debug)] pub struct JobDetails { pub job_id: String, pub logical_plan: Option, pub physical_plan: Option, + pub physical_plan_tree: Option, pub stage_plan: Option, } @@ -238,6 +245,7 @@ pub(crate) enum PlanTab { pub struct JobPlansPopup { pub details: JobDetails, tab: PlanTab, + physical_format: PhysicalFormat, vertical_scroll_position: u16, horizontal_scroll_position: u16, } @@ -247,6 +255,7 @@ impl JobPlansPopup { Self { details, tab, + physical_format: PhysicalFormat::Default, vertical_scroll_position: 0, horizontal_scroll_position: 0, } @@ -256,6 +265,24 @@ impl JobPlansPopup { &self.tab } + pub fn get_physical_format(&self) -> &PhysicalFormat { + &self.physical_format + } + + /// Returns Some(()) if a fetch is needed (tree not cached yet), None if already available. + pub fn set_physical_format(&mut self, fmt: PhysicalFormat) -> Option<()> { + self.physical_format = fmt; + self.vertical_scroll_position = 0; + self.horizontal_scroll_position = 0; + if self.physical_format == PhysicalFormat::Tree + && self.details.physical_plan_tree.is_none() + { + Some(()) + } else { + None + } + } + pub fn set_tab(&mut self, tab: PlanTab) { self.tab = tab; self.vertical_scroll_position = 0; @@ -696,6 +723,7 @@ mod tests { fn make_job_details(id: &str) -> JobDetails { JobDetails { job_id: id.to_string(), + physical_plan_tree: None, logical_plan: None, physical_plan: None, stage_plan: None, diff --git a/ballista-cli/src/tui/domain/jobs/stages.rs b/ballista-cli/src/tui/domain/jobs/stages.rs index 6cdacabfe8..2d2009be25 100644 --- a/ballista-cli/src/tui/domain/jobs/stages.rs +++ b/ballista-cli/src/tui/domain/jobs/stages.rs @@ -14,7 +14,7 @@ // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. - +#![allow(unfulfilled_lint_expectations)] use ratatui::widgets::{ScrollbarState, TableState}; use serde::Deserialize; @@ -67,17 +67,33 @@ pub struct TaskPercentiles { pub p75: u64, } +#[derive(Debug, Default)] +pub struct PlanCache { + pub default: Option, + pub tree: Option, + pub metrics: Option, +} + #[derive(Debug, PartialEq)] pub enum StageDetailsView { None, Tasks, - Plan, + Plan(StagePlanTab), +} + +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq)] +pub enum StagePlanTab { + Default, + Tree, + Metrics, } #[derive(Debug)] pub struct JobStagesPopup { pub job_id: String, pub stages: JobStagesResponse, + pub plan_cache: PlanCache, pub table_state: TableState, pub scrollbar_state: ScrollbarState, pub tasks_table_state: TableState, @@ -92,6 +108,10 @@ impl JobStagesPopup { Self { job_id, scrollbar_state: ScrollbarState::new(stages.stages.len()), + plan_cache: PlanCache { + default: Some(stages.clone()), + ..Default::default() + }, stages, table_state: TableState::default(), tasks_table_state: TableState::default(), @@ -102,6 +122,33 @@ impl JobStagesPopup { } } + pub fn cache_plan_response(&mut self, fmt: StagePlanTab, resp: JobStagesResponse) { + let active_fmt = self.active_plan_format(); + if active_fmt.as_ref() == Some(&fmt) { + self.stages = resp.clone(); + } + match fmt { + StagePlanTab::Default => self.plan_cache.default = Some(resp), + StagePlanTab::Tree => self.plan_cache.tree = Some(resp), + StagePlanTab::Metrics => self.plan_cache.metrics = Some(resp), + } + } + + pub fn cached_response(&self, tab: &StagePlanTab) -> Option<&JobStagesResponse> { + match tab { + StagePlanTab::Default => self.plan_cache.default.as_ref(), + StagePlanTab::Tree => self.plan_cache.tree.as_ref(), + StagePlanTab::Metrics => self.plan_cache.metrics.as_ref(), + } + } + + pub fn active_plan_format(&self) -> Option { + match &self.details_view { + StageDetailsView::Plan(tab) => Some(tab.clone()), + _ => None, + } + } + pub fn plan_vertical_scroll_position(&self) -> u16 { self.plan_vertical_scroll_position } @@ -117,7 +164,7 @@ impl JobStagesPopup { } pub fn set_plan_view(&mut self) { - self.details_view = StageDetailsView::Plan; + self.details_view = StageDetailsView::Plan(StagePlanTab::Default); self.plan_vertical_scroll_position = 0; self.plan_horizontal_scroll_position = 0; } @@ -135,7 +182,21 @@ impl JobStagesPopup { } pub fn is_plan_view(&self) -> bool { - self.details_view == StageDetailsView::Plan + matches!(self.details_view, StageDetailsView::Plan(_)) + } + + #[allow(dead_code)] + pub fn set_tab(&mut self, tab: StagePlanTab) -> Option { + self.details_view = StageDetailsView::Plan(tab.clone()); + self.plan_vertical_scroll_position = 0; + self.plan_horizontal_scroll_position = 0; + + if let Some(cached) = self.cached_response(&tab) { + self.stages = cached.clone(); + None + } else { + Some(tab) + } } pub fn scroll_down(&mut self) { diff --git a/ballista-cli/src/tui/event.rs b/ballista-cli/src/tui/event.rs index 977f1542ee..9858deb06d 100644 --- a/ballista-cli/src/tui/event.rs +++ b/ballista-cli/src/tui/event.rs @@ -20,7 +20,7 @@ use crate::tui::domain::{ executors::Executor, jobs::{ CancelJobResult, Job, JobDetails, - stages::{JobStagesResponse, StagesGraph}, + stages::{JobStagesResponse, StagePlanTab, StagesGraph}, }, metrics::Metric, }; @@ -38,6 +38,8 @@ pub enum UiData { JobDetails(JobDetails), JobStagesGraph(StagesGraph), JobStagesData(String, JobStagesResponse), + #[allow(dead_code)] + JobStagesPlanData(StagePlanTab, JobStagesResponse), ExecutorDetails(Executor), CancelJobResult(CancelJobResult), } diff --git a/ballista-cli/src/tui/http_client.rs b/ballista-cli/src/tui/http_client.rs index e7320bf428..7f71d43702 100644 --- a/ballista-cli/src/tui/http_client.rs +++ b/ballista-cli/src/tui/http_client.rs @@ -103,7 +103,11 @@ impl HttpClient { }) } - pub async fn get_job_details(&self, job_id: &str) -> TuiResult { + pub async fn get_job_details( + &self, + job_id: &str, + plan_format: Option<&str>, + ) -> TuiResult { #[derive(serde::Deserialize, Debug)] struct JobDetailResponse { logical_plan: Option, @@ -111,12 +115,20 @@ impl HttpClient { stage_plan: Option, } - let url = self.url(&format!("job/{}", self.url_encode(job_id))); + let url = self.url(&format!( + "job/{}{}", + self.url_encode(job_id), + match plan_format { + Some(fmt) => format!("?plan_format={fmt}"), + None => String::new(), + } + )); let resp = self.json::(&url).await?; Ok(JobDetails { job_id: job_id.to_string(), logical_plan: resp.logical_plan, physical_plan: resp.physical_plan, + physical_plan_tree: None, stage_plan: resp.stage_plan, }) } @@ -126,15 +138,18 @@ impl HttpClient { self.text(&url).await } - pub async fn get_job_stages(&self, job_id: &str) -> TuiResult { + pub async fn get_job_stages( + &self, + job_id: &str, + plan_format: Option<&str>, + ) -> TuiResult { let url = self.url(&format!( "job/{}/stages{}", self.url_encode(job_id), - if self.config.job.stage.plan.tree { - "?plan_format=tree" - } else { - "" - } + match plan_format { + Some(fmt) => format!("?plan_format={}", fmt), + None => String::new(), + }, )); self.json::(&url).await } diff --git a/ballista-cli/src/tui/infrastructure/config.rs b/ballista-cli/src/tui/infrastructure/config.rs index 3d573e78a8..eac8cef263 100644 --- a/ballista-cli/src/tui/infrastructure/config.rs +++ b/ballista-cli/src/tui/infrastructure/config.rs @@ -30,24 +30,6 @@ 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, @@ -57,8 +39,6 @@ pub struct Settings { pub data_reload_interval_ms: u64, /// How often to refresh the UI. In millis. pub repaint_interval_ms: u64, - - pub job: JobSettings, } const DEFAULT_CONFIG: &str = r#" diff --git a/ballista-cli/src/tui/mod.rs b/ballista-cli/src/tui/mod.rs index eba6585c61..ab520fd7b2 100644 --- a/ballista-cli/src/tui/mod.rs +++ b/ballista-cli/src/tui/mod.rs @@ -291,7 +291,7 @@ pub(crate) mod web { match action { WebKeyAsyncAction::LoadJobStages(id) => { - match http_client.get_job_stages(&id).await { + match http_client.get_job_stages(&id, None).await { Ok(mut stages) => { stages .stages @@ -332,7 +332,7 @@ pub(crate) mod web { } WebKeyAsyncAction::UpdateJobDetails(job_id) => { if let Some(id) = job_id { - match http_client.get_job_details(&id).await { + match http_client.get_job_details(&id, None).await { Ok(details) => { send_data(UiData::JobDetails(details), tx).await; } @@ -344,6 +344,36 @@ pub(crate) mod web { } } } + WebKeyAsyncAction::LoadJobPlanTree(id) => { + match http_client.get_job_details(&id, Some("tree")).await { + Ok(mut details) => { + details.physical_plan_tree = details.physical_plan.take(); + send_data(UiData::JobDetails(details), tx).await; + } + Err(e) => { + tracing::error!("Failed to load tree plan for '{id}': {e:?}") + } + } + } + WebKeyAsyncAction::LoadStagePlan(job_id, tab) => { + use crate::tui::domain::jobs::stages::StagePlanTab; + let fmt = match tab { + StagePlanTab::Default => Some(""), + StagePlanTab::Tree => Some("tree"), + StagePlanTab::Metrics => Some("metrics"), + }; + match http_client.get_job_stages(&job_id, fmt).await { + Ok(mut stages) => { + stages + .stages + .sort_by_key(|s| s.id.parse::().unwrap_or(u64::MAX)); + send_data(UiData::JobStagesPlanData(tab, stages), tx).await; + } + Err(e) => { + tracing::error!("Failed to load stage plan for '{job_id}': {e:?}") + } + } + } WebKeyAsyncAction::ReloadView => { let data = load_tick_data_for_view(&http_client, is_executors, is_jobs).await; diff --git a/ballista-cli/src/tui/ui/footer.rs b/ballista-cli/src/tui/ui/footer.rs index 27a65073f9..986e6dfcb1 100644 --- a/ballista-cli/src/tui/ui/footer.rs +++ b/ballista-cli/src/tui/ui/footer.rs @@ -38,24 +38,26 @@ pub(super) fn render_footer(f: &mut Frame, area: Rect, app: &App) { if app.is_jobs_view() { if app.job_dot_popup.is_some() { - current_view_key_bindings.push(Span::from("[↑↓] Scroll up/down, ")); + current_view_key_bindings.push(Span::from("[↑↓←→] Scroll, ")); current_view_key_bindings.push(Span::from("[Esc] Close popup, ")); } else if app.job_plan_popup.is_some() { - current_view_key_bindings.push(Span::from("[↑↓] Scroll up/down, ")); + current_view_key_bindings.push(Span::from("[↑↓←→] Scroll, ")); current_view_key_bindings.push(Span::from("[s] Stage plan, ")); current_view_key_bindings.push(Span::from("[p] Physical plan, ")); current_view_key_bindings.push(Span::from("[l] Logical plan, ")); current_view_key_bindings.push(Span::from("[Esc] Close popup, ")); } else { - if app.has_more_than_one_job() { - current_view_key_bindings.push(Span::from("[↑↓] Navigate, ")); - current_view_key_bindings.push(Span::from("[/] Search jobs, ")); - current_view_key_bindings.push(Span::from( - "[1,2,3] Sort by first/second/third/... column, ", - )); - } if app.has_selected_job() { if !app.is_job_stages_popup_open() { + if app.has_more_than_one_job() { + current_view_key_bindings + .push(Span::from("[↑↓] Navigate, ")); + current_view_key_bindings + .push(Span::from("[/] Search jobs, ")); + current_view_key_bindings.push(Span::from( + "[1,2,3] Sort by first/second/third/... column, ", + )); + } current_view_key_bindings .push(Span::from("[Enter] View stages, ")); current_view_key_bindings @@ -78,14 +80,26 @@ pub(super) fn render_footer(f: &mut Frame, area: Rect, app: &App) { current_view_key_bindings .push(Span::from("[Esc] Close popup, ")); } else if app.is_job_stage_plan_popup_open() { + current_view_key_bindings.push(Span::from("[↑↓←→] Scroll, ")); + current_view_key_bindings + .push(Span::from("[d] Default format, ")); + current_view_key_bindings + .push(Span::from("[t] Tree format, ")); current_view_key_bindings - .push(Span::from("[↑↓] Scroll up/down, ")); + .push(Span::from("[m] Show metrics, ")); 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("[↑↓] Navigate, ")); current_view_key_bindings .push(Span::from("[Esc] Close popup, ")); } + } else if app.has_more_than_one_job() { + current_view_key_bindings.push(Span::from("[↑↓] Navigate, ")); + current_view_key_bindings.push(Span::from("[/] Search jobs, ")); + current_view_key_bindings.push(Span::from( + "[1,2,3] Sort by first/second/third/... column, ", + )); } } if !current_view_key_bindings.is_empty() { diff --git a/ballista-cli/src/tui/ui/main/jobs/job_plan_popup.rs b/ballista-cli/src/tui/ui/main/jobs/job_plan_popup.rs index 59384f0640..5548ffd6c0 100644 --- a/ballista-cli/src/tui/ui/main/jobs/job_plan_popup.rs +++ b/ballista-cli/src/tui/ui/main/jobs/job_plan_popup.rs @@ -16,7 +16,7 @@ // under the License. use crate::tui::app::App; -use crate::tui::domain::jobs::{JobPlansPopup, PlanTab}; +use crate::tui::domain::jobs::{JobPlansPopup, PhysicalFormat, PlanTab}; use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::prelude::{Color, Style}; @@ -42,13 +42,28 @@ fn render_plans(f: &mut Frame, area: Rect, job_plans: &JobPlansPopup) { let details = &job_plans.details; let tab = job_plans.get_tab(); - let plan = match tab { - PlanTab::Stage => details.stage_plan.as_deref().unwrap_or("N/A"), - PlanTab::Physical => details.physical_plan.as_deref().unwrap_or("N/A"), - PlanTab::Logical => details.logical_plan.as_deref().unwrap_or("N/A"), + let (plan, physical_fmt) = match tab { + PlanTab::Stage => (details.stage_plan.as_deref().unwrap_or("N/A"), ""), + PlanTab::Logical => (details.logical_plan.as_deref().unwrap_or("N/A"), ""), + PlanTab::Physical => match job_plans.get_physical_format() { + PhysicalFormat::Default => ( + details.physical_plan.as_deref().unwrap_or("N/A"), + "(press t to switch to tree format) ", + ), + PhysicalFormat::Tree => ( + details + .physical_plan_tree + .as_deref() + .unwrap_or("Loading..."), + "(press d to switch to default format) ", + ), + }, }; - let title = format!(" {:?} plan for job '{}' ", tab, details.job_id); + let title = format!( + " {:?} plan for job '{}' {}", + tab, details.job_id, physical_fmt + ); let block = Block::default() .title(title) diff --git a/ballista-cli/src/tui/ui/main/jobs/mod.rs b/ballista-cli/src/tui/ui/main/jobs/mod.rs index 97f53054c0..194a2d76e0 100644 --- a/ballista-cli/src/tui/ui/main/jobs/mod.rs +++ b/ballista-cli/src/tui/ui/main/jobs/mod.rs @@ -81,11 +81,12 @@ pub async fn load_job_dot(app: &App, job_id: &str) -> TuiResult<()> { } } +/// Loading whole job's stages to render the popup window #[cfg(not(feature = "web"))] pub async fn load_job_stages_popup(app: &App, job_id: &str) -> TuiResult<()> { let mut stages = app .http_client - .get_job_stages(job_id) + .get_job_stages(job_id, None) .await .inspect(|stages| tracing::trace!("Loaded stages for job '{job_id}': {stages:?}")) .inspect_err(|e| { @@ -102,9 +103,43 @@ pub async fn load_job_stages_popup(app: &App, job_id: &str) -> TuiResult<()> { .await } +/// Loading stage's plan to render the popup window +#[cfg(not(feature = "web"))] +pub async fn load_stage_plan( + app: &App, + job_id: &str, + tab: crate::tui::domain::jobs::stages::StagePlanTab, +) -> TuiResult<()> { + use crate::tui::domain::jobs::stages::StagePlanTab; + + let fmt = match tab { + StagePlanTab::Default => Some(""), + StagePlanTab::Tree => Some("tree"), + StagePlanTab::Metrics => Some("metrics"), + }; + + let mut stages = app + .http_client + .get_job_stages(job_id, fmt) + .await + .inspect(|s| tracing::trace!("Loaded {fmt:?} plan for job '{job_id}': {s:?}")) + .inspect_err(|e| { + tracing::error!("Failed to load {fmt:?} plan for job '{job_id}': {e:?}") + })?; + + stages + .stages + .sort_by_key(|s| s.id.parse::().unwrap_or(u64::MAX)); + + app.send_event(Event::DataLoaded { + data: UiData::JobStagesPlanData(tab, stages), + }) + .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 { + let details = match app.http_client.get_job_details(job_id, None).await { Ok(d) => d, Err(e) => { tracing::error!("Failed to load job details for {job_id}: {e:?}"); diff --git a/ballista-cli/src/tui/ui/main/mod.rs b/ballista-cli/src/tui/ui/main/mod.rs index 5d66c07f82..db9b7639a8 100644 --- a/ballista-cli/src/tui/ui/main/mod.rs +++ b/ballista-cli/src/tui/ui/main/mod.rs @@ -23,7 +23,10 @@ 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}; +pub use jobs::{ + load_job_details, load_job_dot, load_job_stages_popup, load_jobs_data, + load_stage_plan, +}; #[cfg(feature = "web")] pub(crate) use jobs::dot_parser; diff --git a/ballista-cli/src/tui/ui/mod.rs b/ballista-cli/src/tui/ui/mod.rs index 669b2ca047..6e0bc00973 100644 --- a/ballista-cli/src/tui/ui/mod.rs +++ b/ballista-cli/src/tui/ui/mod.rs @@ -36,7 +36,7 @@ pub use main::{ #[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, + load_job_stages_popup, load_jobs_data, load_metrics_data, load_stage_plan, }; use ratatui::{