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..cd1122c45c 100644 --- a/ballista-cli/src/tui/app.rs +++ b/ballista-cli/src/tui/app.rs @@ -28,8 +28,8 @@ use crate::tui::{ ExecutorDetailsPopup, ExecutorsData, SortColumn as ExecutorsSortColumn, }, jobs::{ - CancelJobResult, JobDetails, JobPlansPopup, JobsData, PlanTab, - SortColumn as JobsSortColumn, + CancelJobResult, JobConfigPopup, JobDetails, JobPlansPopup, JobsData, + PlanTab, SortColumn as JobsSortColumn, stages::{JobStagesPopup, StagesGraph}, }, metrics::MetricsData, @@ -51,8 +51,9 @@ use tokio::sync::mpsc::Sender; 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_executor_details_popup, load_executors_data, load_job_config_popup, + load_job_details, load_job_dot, load_job_stages_popup, load_jobs_data, + load_metrics_data, }; const INVALID_DATE: &str = "Invalid date"; @@ -93,6 +94,7 @@ pub(crate) struct App { pub show_scheduler_info: bool, pub job_dot_popup: Option, pub job_plan_popup: Option, + pub job_config_popup: Option, pub job_stages_popup: Option, pub executor_details_popup: Option, @@ -113,6 +115,7 @@ impl App { job_details: None, job_dot_popup: None, job_plan_popup: None, + job_config_popup: None, job_stages_popup: None, executor_details_popup: None, executors_data: ExecutorsData::new(), @@ -142,6 +145,14 @@ impl App { self.input_mode == InputMode::Edit } + pub fn is_main_search_edit_mode(&self) -> bool { + self.is_edit_mode() && self.job_config_popup.is_none() + } + + pub fn is_job_config_search_edit_mode(&self) -> bool { + self.is_edit_mode() && self.job_config_popup.is_some() + } + #[cfg(not(feature = "web"))] pub fn should_quit(&self) -> bool { self.should_quit @@ -190,6 +201,23 @@ impl App { pub async fn on_key(&mut self, key: KeyEvent) -> TuiResult<()> { // Edit mode takes priority over everything if self.is_edit_mode() { + if let Some(popup) = &mut self.job_config_popup { + match key.code { + KeyCode::Esc => { + popup.clear_search(); + self.input_mode = InputMode::View; + } + KeyCode::Backspace => { + popup.pop_search_char(); + } + KeyCode::Char(c) => { + popup.push_search_char(c); + } + _ => {} + } + return Ok(()); + } + match key.code { KeyCode::Esc => { self.search_term.clear(); @@ -287,6 +315,17 @@ impl App { return Ok(()); } + if let Some(ref mut config_popup) = self.job_config_popup { + match key.code { + KeyCode::Up => config_popup.scroll_up(), + KeyCode::Down => config_popup.scroll_down(), + KeyCode::Char('/') => self.input_mode = InputMode::Edit, + KeyCode::Esc => self.job_config_popup = None, + _ => {} + } + return Ok(()); + } + if let Some(ref mut executor_popup) = self.executor_details_popup { match key.code { KeyCode::Up => executor_popup.scroll_up(), @@ -327,6 +366,9 @@ impl App { KeyCode::Char('p') if self.is_jobs_view() => { self.open_job_plan_popup(); } + KeyCode::Char('C') if self.is_jobs_view() => { + self.load_job_config_popup_data().await; + } KeyCode::Char('e') if self.is_scheduler_up() => { self.current_view = Views::Executors; self.load_executors_data().await; @@ -465,6 +507,16 @@ impl App { } } + #[cfg(not(feature = "web"))] + async fn load_job_config_popup_data(&self) { + if let Some(job) = self.jobs_data.selected_job(&self.search_term) { + let job_id = job.job_id.clone(); + if let Err(e) = load_job_config_popup(self, &job_id).await { + tracing::error!("Failed to load job config popup for '{job_id}': {e:?}"); + } + } + } + #[cfg(not(feature = "web"))] async fn load_executor_details_popup_data(&self) { if let Some(executor) = self.executors_data.selected_executor() @@ -623,6 +675,10 @@ impl App { .is_some_and(|popup| popup.is_plan_view()) } + pub fn is_job_config_popup_open(&self) -> bool { + self.job_config_popup.is_some() + } + fn open_job_plan_popup(&mut self) { if self.is_selected_job_completed_or_running() && let Some(details) = &self.job_details @@ -791,6 +847,9 @@ impl App { UiData::JobDetails(details) => { self.job_details = Some(details); } + UiData::JobConfig(popup) => { + self.job_config_popup = Some(popup); + } UiData::JobStagesGraph(graph) => { self.job_dot_popup = Some(graph); } @@ -811,6 +870,19 @@ impl App { #[cfg(feature = "web")] pub fn on_key_sync(&mut self, key: &KeyEvent) -> Option { if self.is_edit_mode() { + if let Some(popup) = &mut self.job_config_popup { + match key.code { + KeyCode::Esc => { + popup.clear_search(); + self.input_mode = InputMode::View; + } + KeyCode::Backspace => popup.pop_search_char(), + KeyCode::Char(c) => popup.push_search_char(c), + _ => {} + } + return None; + } + match key.code { KeyCode::Esc => { self.search_term.clear(); @@ -893,6 +965,17 @@ impl App { return None; } + if let Some(ref mut config_popup) = self.job_config_popup { + match key.code { + KeyCode::Up => config_popup.scroll_up(), + KeyCode::Down => config_popup.scroll_down(), + KeyCode::Char('/') => self.input_mode = InputMode::Edit, + KeyCode::Esc => self.job_config_popup = None, + _ => {} + } + return None; + } + if let Some(ref mut executor_popup) = self.executor_details_popup { match key.code { KeyCode::Up => executor_popup.scroll_up(), @@ -948,6 +1031,13 @@ impl App { self.open_job_plan_popup(); None } + KeyCode::Char('C') if self.is_jobs_view() => { + let job_id = self + .jobs_data + .selected_job(&self.search_term) + .map(|j| j.job_id.clone()); + job_id.map(WebKeyAsyncAction::LoadJobConfig) + } KeyCode::Char('e') if self.is_scheduler_up() => { self.current_view = Views::Executors; Some(WebKeyAsyncAction::ReloadView) @@ -1081,6 +1171,7 @@ pub enum WebKeyAsyncAction { LoadJobStages(String), LoadExecutorDetails(String), LoadJobDot(String), + LoadJobConfig(String), CancelJob(String), UpdateJobDetails(Option), ReloadView, @@ -1095,8 +1186,8 @@ mod tests { use crate::tui::domain::{ SchedulerState, SortOrder, executors::{Executor, ExecutorDetailsPopup, OsInfo, Specification}, - jobs::Job, jobs::stages::{JobStagesPopup, JobStagesResponse}, + jobs::{Job, JobConfigEntry, JobConfigPopup}, }; use crate::tui::infrastructure::Settings; @@ -1203,6 +1294,22 @@ mod tests { } } + fn make_job_config_popup() -> JobConfigPopup { + JobConfigPopup::new( + "j1".to_string(), + vec![ + JobConfigEntry { + key: "ballista.job.name".to_string(), + value: "Remote SQL Example".to_string(), + }, + JobConfigEntry { + key: "datafusion.execution.batch_size".to_string(), + value: "8192".to_string(), + }, + ], + ) + } + #[test] fn open_job_plan_popup_opens_for_running_job_with_details() { let mut app = make_app(); @@ -1428,6 +1535,26 @@ mod tests { assert!(app.is_executor_details_popup_open()); } + #[test] + fn is_job_config_popup_open_false_when_none() { + let app = make_app(); + assert!(!app.is_job_config_popup_open()); + } + + #[test] + fn is_job_config_popup_open_true_when_some() { + let mut app = make_app(); + app.job_config_popup = Some(make_job_config_popup()); + assert!(app.is_job_config_popup_open()); + } + + #[test] + fn apply_ui_data_sets_job_config_popup() { + let mut app = make_app(); + app.apply_ui_data(crate::tui::event::UiData::JobConfig(make_job_config_popup())); + assert!(app.job_config_popup.is_some()); + } + // --- format_size tests --- #[test] diff --git a/ballista-cli/src/tui/domain/jobs.rs b/ballista-cli/src/tui/domain/jobs.rs index df4e89cffd..22cf1dd23d 100644 --- a/ballista-cli/src/tui/domain/jobs.rs +++ b/ballista-cli/src/tui/domain/jobs.rs @@ -19,6 +19,7 @@ pub mod stages; use ratatui::widgets::{ScrollbarState, TableState}; use serde::Deserialize; +use std::collections::BTreeMap; #[derive(Deserialize, Clone, Debug)] pub struct Job { @@ -227,6 +228,106 @@ pub struct JobDetails { pub stage_plan: Option, } +#[derive(Clone, Debug, PartialEq)] +pub struct JobConfigEntry { + pub key: String, + pub value: String, +} + +#[derive(Clone, Debug)] +pub struct JobConfigPopup { + pub job_id: String, + pub entries: Vec, + pub search_term: String, + pub table_state: TableState, + pub scrollbar_state: ScrollbarState, +} + +impl JobConfigPopup { + pub fn new(job_id: String, entries: Vec) -> Self { + let has_entries = !entries.is_empty(); + let len = entries.len(); + Self { + job_id, + entries, + search_term: String::new(), + table_state: TableState::default().with_selected(has_entries.then_some(0)), + scrollbar_state: ScrollbarState::new(len).position(0), + } + } + + pub fn filtered_entries(&self) -> Vec<&JobConfigEntry> { + let search_term = self.search_term.to_lowercase(); + if search_term.is_empty() { + self.entries.iter().collect() + } else { + self.entries + .iter() + .filter(|entry| { + entry.key.to_lowercase().contains(&search_term) + || entry.value.to_lowercase().contains(&search_term) + }) + .collect() + } + } + + pub fn scroll_down(&mut self) { + let len = self.filtered_entries().len(); + if len == 0 { + self.table_state.select(None); + return; + } + + let next = match self.table_state.selected() { + Some(selected) if selected + 1 < len => Some(selected + 1), + Some(_) => None, + None => Some(0), + }; + self.table_state.select(next); + self.scrollbar_state = self.scrollbar_state.position(next.unwrap_or(0)); + } + + pub fn scroll_up(&mut self) { + let len = self.filtered_entries().len(); + if len == 0 { + self.table_state.select(None); + return; + } + + let next = match self.table_state.selected() { + Some(0) => None, + Some(selected) => Some(selected - 1), + None => Some(len - 1), + }; + self.table_state.select(next); + self.scrollbar_state = self.scrollbar_state.position(next.unwrap_or(0)); + } + + pub fn push_search_char(&mut self, c: char) { + self.search_term.push(c); + self.reset_selection_for_filter(); + } + + pub fn pop_search_char(&mut self) { + self.search_term.pop(); + self.reset_selection_for_filter(); + } + + pub fn clear_search(&mut self) { + self.search_term.clear(); + self.reset_selection_for_filter(); + } + + fn reset_selection_for_filter(&mut self) { + let len = self.filtered_entries().len(); + let selected = if len == 0 { None } else { Some(0) }; + self.table_state.select(selected); + self.scrollbar_state = ScrollbarState::new(len).position(0); + } +} + +pub type JobConfigResponse = BTreeMap; + #[derive(Debug, Clone, PartialEq)] pub(crate) enum PlanTab { Stage, @@ -293,7 +394,8 @@ impl JobPlansPopup { mod tests { use crate::tui::domain::SortOrder; use crate::tui::domain::jobs::{ - Job, JobDetails, JobPlansPopup, JobsData, PlanTab, SortColumn, + Job, JobConfigEntry, JobConfigPopup, JobDetails, JobPlansPopup, JobsData, + PlanTab, SortColumn, }; #[expect(clippy::too_many_arguments)] @@ -756,4 +858,58 @@ mod tests { assert_eq!(popup.vertical_scroll_position, 0); assert_eq!(popup.horizontal_scroll_position, 0); } + + #[test] + fn job_config_popup_filters_by_key_and_value() { + let popup = JobConfigPopup::new( + "j1".to_string(), + vec![ + JobConfigEntry { + key: "ballista.job.name".to_string(), + value: "Remote SQL Example".to_string(), + }, + JobConfigEntry { + key: "datafusion.execution.batch_size".to_string(), + value: "8192".to_string(), + }, + ], + ); + + let mut popup = popup; + popup.push_search_char('8'); + assert_eq!(popup.filtered_entries().len(), 1); + assert_eq!( + popup.filtered_entries()[0].key, + "datafusion.execution.batch_size" + ); + + popup.clear_search(); + popup.push_search_char('n'); + popup.push_search_char('a'); + popup.push_search_char('m'); + popup.push_search_char('e'); + assert_eq!(popup.filtered_entries().len(), 1); + assert_eq!(popup.filtered_entries()[0].key, "ballista.job.name"); + } + + #[test] + fn job_config_popup_search_resets_selection() { + let mut popup = JobConfigPopup::new( + "j1".to_string(), + vec![ + JobConfigEntry { + key: "a".to_string(), + value: "1".to_string(), + }, + JobConfigEntry { + key: "b".to_string(), + value: "2".to_string(), + }, + ], + ); + popup.scroll_down(); + assert_eq!(popup.table_state.selected(), Some(1)); + popup.push_search_char('a'); + assert_eq!(popup.table_state.selected(), Some(0)); + } } diff --git a/ballista-cli/src/tui/event.rs b/ballista-cli/src/tui/event.rs index 2738259248..e48a5032db 100644 --- a/ballista-cli/src/tui/event.rs +++ b/ballista-cli/src/tui/event.rs @@ -19,7 +19,7 @@ use crate::tui::domain::{ SchedulerState, executors::Executor, jobs::{ - CancelJobResult, Job, JobDetails, + CancelJobResult, Job, JobConfigPopup, JobDetails, stages::{JobStagesResponse, StagesGraph}, }, metrics::Metric, @@ -36,6 +36,7 @@ pub enum UiData { Metrics(Vec), Jobs(Vec), JobDetails(JobDetails), + JobConfig(JobConfigPopup), JobStagesGraph(StagesGraph), JobStagesData(String, JobStagesResponse), ExecutorDetails(Executor), diff --git a/ballista-cli/src/tui/http_client.rs b/ballista-cli/src/tui/http_client.rs index e7320bf428..bdee986b79 100644 --- a/ballista-cli/src/tui/http_client.rs +++ b/ballista-cli/src/tui/http_client.rs @@ -26,7 +26,10 @@ use crate::tui::{ domain::{ SchedulerState, executors::Executor, - jobs::{CancelJobResponse, Job, JobDetails, stages::JobStagesResponse}, + jobs::{ + CancelJobResponse, Job, JobConfigResponse, JobDetails, + stages::JobStagesResponse, + }, metrics::{Metric, MetricsResponse}, }, error::TuiError, @@ -126,6 +129,11 @@ impl HttpClient { self.text(&url).await } + pub async fn get_job_config(&self, job_id: &str) -> TuiResult { + let url = self.url(&format!("job/{}/config", self.url_encode(job_id))); + self.json::(&url).await + } + pub async fn get_job_stages(&self, job_id: &str) -> TuiResult { let url = self.url(&format!( "job/{}/stages{}", diff --git a/ballista-cli/src/tui/mod.rs b/ballista-cli/src/tui/mod.rs index d64d87c59d..4035364c12 100644 --- a/ballista-cli/src/tui/mod.rs +++ b/ballista-cli/src/tui/mod.rs @@ -112,7 +112,7 @@ pub(crate) mod web { use crate::tui::{ TuiResult, app::{App, WebKeyAsyncAction}, - domain::jobs::CancelJobResult, + domain::jobs::{CancelJobResult, JobConfigEntry, JobConfigPopup}, event::{ Event, UiData, web::{EventHandler, Sender}, @@ -319,6 +319,24 @@ pub(crate) mod web { } Err(e) => tracing::error!("Failed to load job dot for '{id}': {e:?}"), }, + WebKeyAsyncAction::LoadJobConfig(id) => { + match http_client.get_job_config(&id).await { + Ok(config) => { + let entries = config + .into_iter() + .map(|(key, value)| JobConfigEntry { key, value }) + .collect(); + send_data( + UiData::JobConfig(JobConfigPopup::new(id, entries)), + tx, + ) + .await; + } + Err(e) => { + tracing::error!("Failed to load job config for '{id}': {e:?}") + } + } + } WebKeyAsyncAction::CancelJob(id) => { let result = match http_client.cancel_job(&id).await { Ok(resp) if resp.canceled => CancelJobResult::Success { job_id: id }, diff --git a/ballista-cli/src/tui/ui/footer.rs b/ballista-cli/src/tui/ui/footer.rs index 27a65073f9..77290fad02 100644 --- a/ballista-cli/src/tui/ui/footer.rs +++ b/ballista-cli/src/tui/ui/footer.rs @@ -46,6 +46,10 @@ pub(super) fn render_footer(f: &mut Frame, area: Rect, app: &App) { 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.is_job_config_popup_open() { + current_view_key_bindings.push(Span::from("[↑↓] Navigate, ")); + current_view_key_bindings.push(Span::from("[/] Search config, ")); + 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, ")); @@ -60,6 +64,8 @@ pub(super) fn render_footer(f: &mut Frame, area: Rect, app: &App) { .push(Span::from("[Enter] View stages, ")); current_view_key_bindings .push(Span::from("[g] View job stages graph, ")); + current_view_key_bindings + .push(Span::from("[C] View job config, ")); if app.is_selected_job_cancelable() { current_view_key_bindings diff --git a/ballista-cli/src/tui/ui/help_overlay.rs b/ballista-cli/src/tui/ui/help_overlay.rs index 37ea300b1d..f650ecda40 100644 --- a/ballista-cli/src/tui/ui/help_overlay.rs +++ b/ballista-cli/src/tui/ui/help_overlay.rs @@ -57,6 +57,7 @@ pub(crate) fn render_help_overlay(f: &mut Frame, app: &App) { " 1/2/... Sort by first/second/... column", style, )), + Line::from(Span::styled(" C Show job config items", style)), Line::from(Span::styled( " g Dot graph if a completed job is selected", style, diff --git a/ballista-cli/src/tui/ui/main/jobs/job_config_popup.rs b/ballista-cli/src/tui/ui/main/jobs/job_config_popup.rs new file mode 100644 index 0000000000..de6b498aa8 --- /dev/null +++ b/ballista-cli/src/tui/ui/main/jobs/job_config_popup.rs @@ -0,0 +1,112 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use crate::tui::app::App; +use crate::tui::domain::jobs::JobConfigEntry; +use crate::tui::ui::search_box::render_search_input; +use crate::tui::ui::vertical_scrollbar::{render_scrollbar, split_area}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout}; +use ratatui::prelude::{Color, Style}; +use ratatui::text::Text; +use ratatui::widgets::{ + Block, BorderType, Borders, Cell, Clear, HighlightSpacing, Paragraph, Row, Table, +}; + +pub(crate) fn render_job_config_popup(f: &mut Frame, app: &App) { + let Some(popup) = &app.job_config_popup else { + return; + }; + + let area = crate::tui::ui::centered_rect(85, 80, f.area()); + f.render_widget(Clear, area); + + let areas = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).split(area); + + render_search_input( + f, + areas[0], + &popup.search_term, + app.is_job_config_search_edit_mode(), + " Search config [/ to activate] ", + " Search config ", + ); + + let filtered = popup.filtered_entries(); + if filtered.is_empty() { + let block = Block::default() + .title(format!(" Job config for '{}' ", popup.job_id)) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::LightCyan)) + .border_type(BorderType::Thick); + let paragraph = Paragraph::new("No matching config entries").block(block); + f.render_widget(paragraph, areas[1]); + return; + } + + let table_area = split_area(areas[1]); + let mut table_state = popup.table_state; + let mut scrollbar_state = popup.scrollbar_state; + + let header = Row::new(vec![ + Cell::from(Text::from("Key")), + Cell::from(Text::from("Value")), + ]) + .style( + Style::default() + .fg(Color::LightYellow) + .bg(Color::Black) + .bold(), + ); + + let rows = filtered + .iter() + .enumerate() + .map(|(i, entry)| row_for_entry(i, entry)); + + let table = Table::new( + rows, + [Constraint::Percentage(40), Constraint::Percentage(60)], + ) + .header(header) + .block( + Block::default() + .title(format!(" Job config for '{}' ", popup.job_id)) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::LightCyan)) + .border_type(BorderType::Thick), + ) + .row_highlight_style(Style::default().bg(Color::DarkGray)) + .highlight_spacing(HighlightSpacing::Always); + + f.render_stateful_widget(table, table_area[0], &mut table_state); + render_scrollbar(f, table_area[1], &mut scrollbar_state); +} + +fn row_for_entry<'a>(i: usize, entry: &&'a JobConfigEntry) -> Row<'a> { + let color = if i.is_multiple_of(2) { + Color::DarkGray + } else { + Color::Black + }; + + Row::new(vec![ + Cell::from(entry.key.clone()), + Cell::from(entry.value.clone()), + ]) + .style(Style::default().bg(color)) +} diff --git a/ballista-cli/src/tui/ui/main/jobs/mod.rs b/ballista-cli/src/tui/ui/main/jobs/mod.rs index 97f53054c0..93cc12b3cd 100644 --- a/ballista-cli/src/tui/ui/main/jobs/mod.rs +++ b/ballista-cli/src/tui/ui/main/jobs/mod.rs @@ -16,6 +16,7 @@ // under the License. pub(crate) mod dot_parser; +pub mod job_config_popup; pub mod job_dot_popup; pub mod job_plan_popup; pub mod job_stages_popup; @@ -81,6 +82,27 @@ pub async fn load_job_dot(app: &App, job_id: &str) -> TuiResult<()> { } } +#[cfg(not(feature = "web"))] +pub async fn load_job_config_popup(app: &App, job_id: &str) -> TuiResult<()> { + let config = match app.http_client.get_job_config(job_id).await { + Ok(config) => config, + Err(e) => { + tracing::error!("Failed to load job config for {job_id}: {e:?}"); + return Ok(()); + } + }; + + let entries = config + .into_iter() + .map(|(key, value)| JobConfigEntry { key, value }) + .collect(); + + app.send_event(Event::DataLoaded { + data: UiData::JobConfig(JobConfigPopup::new(job_id.to_string(), entries)), + }) + .await +} + #[cfg(not(feature = "web"))] pub async fn load_job_stages_popup(app: &App, job_id: &str) -> TuiResult<()> { let mut stages = app diff --git a/ballista-cli/src/tui/ui/main/mod.rs b/ballista-cli/src/tui/ui/main/mod.rs index 5d66c07f82..db243f3757 100644 --- a/ballista-cli/src/tui/ui/main/mod.rs +++ b/ballista-cli/src/tui/ui/main/mod.rs @@ -23,13 +23,16 @@ 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_config_popup, load_job_details, load_job_dot, load_job_stages_popup, + load_jobs_data, +}; #[cfg(feature = "web")] pub(crate) use jobs::dot_parser; pub use jobs::{ - job_dot_popup, job_plan_popup, job_stages_popup, render_jobs, stage_plan_popup, - stage_tasks_popup, + job_config_popup, job_dot_popup, job_plan_popup, job_stages_popup, render_jobs, + stage_plan_popup, stage_tasks_popup, }; #[cfg(not(feature = "web"))] pub use metrics::load_metrics_data; diff --git a/ballista-cli/src/tui/ui/mod.rs b/ballista-cli/src/tui/ui/mod.rs index 669b2ca047..8b02f574a5 100644 --- a/ballista-cli/src/tui/ui/mod.rs +++ b/ballista-cli/src/tui/ui/mod.rs @@ -30,13 +30,15 @@ use footer::render_footer; #[cfg(feature = "web")] pub(crate) use main::dot_parser; pub use main::{ - executor_details_popup, job_dot_popup, job_plan_popup, job_stages_popup, - render_executors, render_jobs, render_metrics, stage_plan_popup, stage_tasks_popup, + executor_details_popup, job_config_popup, job_dot_popup, job_plan_popup, + job_stages_popup, render_executors, render_jobs, render_metrics, stage_plan_popup, + stage_tasks_popup, }; #[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_executor_details_popup, load_executors_data, load_job_config_popup, + load_job_details, load_job_dot, load_job_stages_popup, load_jobs_data, + load_metrics_data, }; use ratatui::{ @@ -77,6 +79,8 @@ pub(crate) fn render(f: &mut Frame, app: &App) { job_dot_popup::render_job_dot_popup(f, app); } else if app.job_plan_popup.is_some() { job_plan_popup::render_job_plan_popup(f, app); + } else if app.job_config_popup.is_some() { + job_config_popup::render_job_config_popup(f, app); } else if app.executor_details_popup.is_some() { executor_details_popup::render_executor_details_popup(f, app); } diff --git a/ballista-cli/src/tui/ui/search_box.rs b/ballista-cli/src/tui/ui/search_box.rs index 2bbb475145..d93fd7fbd3 100644 --- a/ballista-cli/src/tui/ui/search_box.rs +++ b/ballista-cli/src/tui/ui/search_box.rs @@ -21,18 +21,36 @@ use ratatui::prelude::{Color, Line, Span, Style}; use ratatui::widgets::{Block, Borders, Paragraph}; pub(crate) fn render_search_box(f: &mut Frame, area: Rect, app: &crate::tui::app::App) { - let (title, border_style) = if app.is_edit_mode() { - (" Search ", Style::default().fg(Color::Yellow)) + render_search_input( + f, + area, + &app.search_term, + app.is_main_search_edit_mode(), + " Search [/ to activate] ", + " Search ", + ); +} + +pub(crate) fn render_search_input( + f: &mut Frame, + area: Rect, + search_term: &str, + is_edit_mode: bool, + inactive_title: &str, + active_title: &str, +) { + let (title, border_style) = if is_edit_mode { + (active_title, Style::default().fg(Color::Yellow)) } else { - (" Search [/ to activate] ", Style::default().dim()) + (inactive_title, Style::default().dim()) }; - let display_text = if app.is_edit_mode() { - let search_term = Span::from(app.search_term.clone()); + let display_text = if is_edit_mode { + let search_term = Span::from(search_term.to_string()); let cursor = Span::from("_").style(Style::default().bold().yellow()); Line::from(vec![search_term, cursor]) } else { - Line::from(Span::from(app.search_term.clone())) + Line::from(Span::from(search_term.to_string())) }; let block = Block::default()