use ratatui::layout::{Constraint, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap}; use ratatui::Frame; pub fn heading_row_style() -> Style { neutral_text_style().add_modifier(Modifier::BOLD) } fn neutral_text_style() -> Style { Style::default() } fn neutral_title_span(title: &str) -> Span<'static> { Span::styled( title.to_string(), neutral_text_style().add_modifier(Modifier::BOLD), ) } fn neutral_border_style(color: Color) -> Style { Style::default().fg(color) } pub fn status_level_from_agent_status(agent_status: Option<&String>) -> StatusLevel { match agent_status.map(|s| s.as_str()) { Some("critical") => StatusLevel::Error, Some("warning") => StatusLevel::Warning, Some("ok") => StatusLevel::Ok, Some("unknown") => StatusLevel::Unknown, _ => StatusLevel::Unknown, } } pub fn connection_status_message(connection_status: &crate::app::ConnectionStatus, last_error: &Option) -> String { use crate::app::ConnectionStatus; match connection_status { ConnectionStatus::Connected => "Connected".to_string(), ConnectionStatus::Timeout => { if let Some(error) = last_error { format!("Timeout: {}", error) } else { "Keep-alive timeout".to_string() } }, ConnectionStatus::Error => { if let Some(error) = last_error { format!("Error: {}", error) } else { "Connection error".to_string() } }, ConnectionStatus::Unknown => "No data received".to_string(), } } pub fn render_placeholder(frame: &mut Frame, area: Rect, title: &str, message: &str) { let block = Block::default() .title(neutral_title_span(title)) .borders(Borders::ALL) .border_style(neutral_border_style(Color::Gray)); let inner = block.inner(area); frame.render_widget(block, area); frame.render_widget( Paragraph::new(Line::from(message)) .wrap(Wrap { trim: true }) .style(neutral_text_style()), inner, ); } fn is_last_sub_service_in_group(rows: &[WidgetRow], current_idx: usize, parent_service: &Option) -> bool { if let Some(parent) = parent_service { // Look ahead to see if there are any more sub-services for this parent for i in (current_idx + 1)..rows.len() { if let Some(ref other_parent) = rows[i].sub_service { if other_parent == parent { return false; // Found another sub-service for same parent } } } true // No more sub-services found for this parent } else { false // Not a sub-service } } pub fn render_widget_data(frame: &mut Frame, area: Rect, data: WidgetData) { render_combined_widget_data(frame, area, data.title, data.status, vec![data.dataset]); } pub fn render_combined_widget_data(frame: &mut Frame, area: Rect, title: String, status: Option, datasets: Vec) { if datasets.is_empty() { return; } // Create border and title - determine color from widget status let border_color = status.as_ref() .map(|s| s.status.to_color()) .unwrap_or(Color::Reset); let block = Block::default() .title(neutral_title_span(&title)) .borders(Borders::ALL) .border_style(neutral_border_style(border_color)); let inner = block.inner(area); frame.render_widget(block, area); // Split multi-row datasets into single-row datasets when wrapping is needed let split_datasets = split_multirow_datasets_with_area(datasets, inner); let mut current_y = inner.y; for dataset in split_datasets.iter() { if current_y >= inner.y + inner.height { break; // No more space } current_y += render_dataset_with_wrapping(frame, dataset, inner, current_y); } } fn split_multirow_datasets_with_area(datasets: Vec, inner: Rect) -> Vec { let mut result = Vec::new(); for dataset in datasets { if dataset.rows.len() <= 1 { // Single row or empty - keep as is result.push(dataset); } else { // Multiple rows - check if wrapping is needed using actual available width if dataset_needs_wrapping_with_width(&dataset, inner.width) { // Split into separate datasets for individual wrapping for row in dataset.rows { let single_row_dataset = WidgetDataSet { colnames: dataset.colnames.clone(), status: dataset.status.clone(), rows: vec![row], }; result.push(single_row_dataset); } } else { // No wrapping needed - keep as single dataset result.push(dataset); } } } result } fn dataset_needs_wrapping_with_width(dataset: &WidgetDataSet, available_width: u16) -> bool { // Calculate column widths let mut column_widths = Vec::new(); for (col_index, colname) in dataset.colnames.iter().enumerate() { let mut max_width = colname.chars().count() as u16; // Check data rows for this column width for row in &dataset.rows { if let Some(widget_value) = row.values.get(col_index) { let data_width = widget_value.chars().count() as u16; max_width = max_width.max(data_width); } } let column_width = (max_width + 1).min(25).max(6); column_widths.push(column_width); } // Calculate total width needed let status_col_width = 1u16; let col_spacing = 1u16; let mut total_width = status_col_width + col_spacing; for &col_width in &column_widths { total_width += col_width + col_spacing; } total_width > available_width } fn render_dataset_with_wrapping(frame: &mut Frame, dataset: &WidgetDataSet, inner: Rect, start_y: u16) -> u16 { if dataset.colnames.is_empty() || dataset.rows.is_empty() { return 0; } // Calculate column widths let mut column_widths = Vec::new(); for (col_index, colname) in dataset.colnames.iter().enumerate() { let mut max_width = colname.chars().count() as u16; // Check data rows for this column width for row in &dataset.rows { if let Some(widget_value) = row.values.get(col_index) { let data_width = widget_value.chars().count() as u16; max_width = max_width.max(data_width); } } let column_width = (max_width + 1).min(25).max(6); column_widths.push(column_width); } let status_col_width = 1u16; let col_spacing = 1u16; let available_width = inner.width; // Determine how many columns fit let mut total_width = status_col_width + col_spacing; let mut cols_that_fit = 0; for &col_width in &column_widths { let new_total = total_width + col_width + col_spacing; if new_total <= available_width { total_width = new_total; cols_that_fit += 1; } else { break; } } if cols_that_fit == 0 { cols_that_fit = 1; // Always show at least one column } let mut current_y = start_y; let mut col_start = 0; let mut is_continuation = false; // Render wrapped sections while col_start < dataset.colnames.len() { let col_end = (col_start + cols_that_fit).min(dataset.colnames.len()); let section_colnames = &dataset.colnames[col_start..col_end]; let section_widths = &column_widths[col_start..col_end]; // Render header for this section let mut header_cells = vec![]; // Status cell if is_continuation { header_cells.push(Cell::from("↳")); } else { header_cells.push(Cell::from("")); } // Column headers for colname in section_colnames { header_cells.push(Cell::from(Line::from(vec![Span::styled( colname.clone(), heading_row_style(), )]))); } let header_row = Row::new(header_cells).style(heading_row_style()); // Build constraint widths for this section let mut constraints = vec![Constraint::Length(status_col_width)]; for &width in section_widths { constraints.push(Constraint::Length(width)); } let header_table = Table::new(vec![header_row]) .widths(&constraints) .column_spacing(col_spacing) .style(neutral_text_style()); frame.render_widget(header_table, Rect { x: inner.x, y: current_y, width: inner.width, height: 1, }); current_y += 1; // Render data rows for this section for (row_idx, row) in dataset.rows.iter().enumerate() { if current_y >= inner.y + inner.height { break; } // Check if this is a sub-service - if so, render as full-width row if row.sub_service.is_some() && col_start == 0 { // Sub-service: render as full-width spanning row let is_last_sub_service = is_last_sub_service_in_group(&dataset.rows, row_idx, &row.sub_service); let tree_char = if is_last_sub_service { "└─" } else { "├─" }; let service_name = row.values.get(0).cloned().unwrap_or_default(); let status_icon = match &row.status { Some(s) => { let color = s.status.to_color(); let icon = s.status.to_icon(); Span::styled(icon.to_string(), Style::default().fg(color)) }, None => Span::raw(""), }; let full_content = format!("{} {}", tree_char, service_name); let full_cell = Cell::from(Line::from(vec![ status_icon, Span::raw(" "), Span::styled(full_content, neutral_text_style()), ])); let full_row = Row::new(vec![full_cell]); let full_constraints = vec![Constraint::Length(inner.width)]; let full_table = Table::new(vec![full_row]) .widths(&full_constraints) .style(neutral_text_style()); frame.render_widget(full_table, Rect { x: inner.x, y: current_y, width: inner.width, height: 1, }); } else if row.sub_service.is_none() { // Regular service: render with columns as normal let mut cells = vec![]; // Status cell (only show on first section) if col_start == 0 { match &row.status { Some(s) => { let color = s.status.to_color(); let icon = s.status.to_icon(); cells.push(Cell::from(Line::from(vec![Span::styled( icon.to_string(), Style::default().fg(color), )]))); }, None => cells.push(Cell::from("")), } } else { cells.push(Cell::from("")); } // Data cells for this section for col_idx in col_start..col_end { if let Some(content) = row.values.get(col_idx) { if content.is_empty() { cells.push(Cell::from("")); } else { cells.push(Cell::from(Line::from(vec![Span::styled( content.to_string(), neutral_text_style(), )]))); } } else { cells.push(Cell::from("")); } } let data_row = Row::new(cells); let data_table = Table::new(vec![data_row]) .widths(&constraints) .column_spacing(col_spacing) .style(neutral_text_style()); frame.render_widget(data_table, Rect { x: inner.x, y: current_y, width: inner.width, height: 1, }); } current_y += 1; // Render description rows if any exist for description in &row.description { if current_y >= inner.y + inner.height { break; } // Render description as a single cell spanning the entire width let desc_cell = Cell::from(Line::from(vec![Span::styled( format!(" {}", description), Style::default().fg(Color::Blue), )])); let desc_row = Row::new(vec![desc_cell]); let desc_constraints = vec![Constraint::Length(inner.width)]; let desc_table = Table::new(vec![desc_row]) .widths(&desc_constraints) .style(neutral_text_style()); frame.render_widget(desc_table, Rect { x: inner.x, y: current_y, width: inner.width, height: 1, }); current_y += 1; } } col_start = col_end; is_continuation = true; } current_y - start_y } #[derive(Clone)] pub struct WidgetData { pub title: String, pub status: Option, pub dataset: WidgetDataSet, } #[derive(Clone)] pub struct WidgetDataSet { pub colnames: Vec, pub status: Option, pub rows: Vec, } #[derive(Clone)] pub struct WidgetRow { pub status: Option, pub values: Vec, pub description: Vec, pub sub_service: Option, } #[derive(Clone, Copy, Debug)] pub enum StatusLevel { Ok, Warning, Error, Unknown, } #[derive(Clone)] pub struct WidgetStatus { pub status: StatusLevel, } impl WidgetData { pub fn new(title: impl Into, status: Option, colnames: Vec) -> Self { Self { title: title.into(), status: status.clone(), dataset: WidgetDataSet { colnames, status, rows: Vec::new(), }, } } pub fn add_row(&mut self, status: Option, description: Vec, values: Vec) -> &mut Self { self.add_row_with_sub_service(status, description, values, None) } pub fn add_row_with_sub_service(&mut self, status: Option, description: Vec, values: Vec, sub_service: Option) -> &mut Self { self.dataset.rows.push(WidgetRow { status, values, description, sub_service, }); self } } impl WidgetDataSet { pub fn new(colnames: Vec, status: Option) -> Self { Self { colnames, status, rows: Vec::new(), } } pub fn add_row(&mut self, status: Option, description: Vec, values: Vec) -> &mut Self { self.add_row_with_sub_service(status, description, values, None) } pub fn add_row_with_sub_service(&mut self, status: Option, description: Vec, values: Vec, sub_service: Option) -> &mut Self { self.rows.push(WidgetRow { status, values, description, sub_service, }); self } } impl WidgetStatus { pub fn new(status: StatusLevel) -> Self { Self { status, } } } impl StatusLevel { pub fn to_color(self) -> Color { match self { StatusLevel::Ok => Color::Green, StatusLevel::Warning => Color::Yellow, StatusLevel::Error => Color::Red, StatusLevel::Unknown => Color::Reset, // Terminal default } } pub fn to_icon(self) -> &'static str { match self { StatusLevel::Ok => "✔", StatusLevel::Warning => "!", StatusLevel::Error => "✖", StatusLevel::Unknown => "?", } } }