diff --git a/src/ui/app.rs b/src/ui/app.rs index 18fa818..4a6c144 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -18,7 +18,7 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span, Text}, - widgets::{Block, Borders, Padding, Paragraph}, + widgets::{Block, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation}, }; use std::{ @@ -128,14 +128,26 @@ impl App { render_header(frame, chunks[0], &self.state); + let mut main_constraint = Vec::with_capacity(2); + main_constraint.push(Constraint::Min(1)); + + if self.state.show_vertical_scrollbar(chunks[1].height) { + main_constraint.push(Constraint::Length(1)); + } + + let main_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints(main_constraint) + .split(chunks[1]); + if self.state.is_loading { - Self::render_loading(frame, chunks[1]); + Self::render_loading(frame, main_chunks[0]); } else if let Some(error) = &self.state.error_message { - Self::render_error(frame, chunks[1], error); + Self::render_error(frame, main_chunks[0], error); } else if self.state.groups.is_empty() || self.state.get_all_monitors().is_empty() { - Self::render_no_data(frame, chunks[1]); + Self::render_no_data(frame, main_chunks[0]); } else { - render_monitor_list(frame, chunks[1], &self.state); + Self::render_main(frame, main_chunks.to_vec(), &mut self.state); } let seconds_until_update = self @@ -148,6 +160,22 @@ impl App { }); } + fn render_main(frame: &mut Frame, area: Vec, state: &mut DashboardViewState) { + render_monitor_list(frame, area[0], state); + + let total_monitors = state.get_all_monitors().len(); + + if area[0].height > total_monitors as u16 { + return; + } + + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")); + + frame.render_stateful_widget(scrollbar, area[1], &mut state.scroll_state); + } + fn handle_events(&mut self) -> io::Result<()> { let timeout = Duration::from_secs(1); @@ -159,6 +187,12 @@ impl App { match key.code { KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true, + KeyCode::Up | KeyCode::Char('k') => { + self.state.scroll_state.prev(); + } + KeyCode::Down | KeyCode::Char('j') => { + self.state.scroll_state.next(); + } _ => {} } } diff --git a/src/ui/components/monitor_list.rs b/src/ui/components/monitor_list.rs index e0b1c91..31dd468 100644 --- a/src/ui/components/monitor_list.rs +++ b/src/ui/components/monitor_list.rs @@ -16,43 +16,29 @@ use ratatui::{ const STATUS_LINE_LENGTH: usize = 100; const MAX_NAME_LENGTH: usize = 30; -pub fn render_monitor_list(frame: &mut Frame, area: Rect, state: &DashboardViewState) { - let group_areas = layout_groups(area, &state.groups); +pub fn render_monitor_list(main_frame: &mut Frame, area: Rect, state: &DashboardViewState) { + let constraints: Vec = state + .groups + .iter() + .map(|g| { + let height_neeed = 3 + g.monitors.len(); + Constraint::Length(height_neeed as u16) + }) + .collect(); + + if constraints.is_empty() { + return; + } + + let group_areas = Layout::vertical(constraints).split(area); for (i, (group, &group_area)) in state.groups.iter().zip(group_areas.iter()).enumerate() { - render_group(frame, group_area, group, i == 0); + if group_area.height > 0 { + render_group(main_frame, group_area, group, i == 0); + } } } -fn layout_groups(area: Rect, groups: &[GroupViewState]) -> Vec { - let total_lines: usize = groups.iter().map(|g| g.monitors.len() + 1).sum(); - - if total_lines == 0 { - return Vec::new(); - } - - let content_height = area.height.saturating_sub(2); - let line_height = content_height as usize / total_lines; - - let mut current_y = area.y + 1; - let mut areas = Vec::with_capacity(groups.len()); - - for group in groups { - let group_lines = group.monitors.len() + 2; - let group_height = (group_lines + line_height).max(1); - areas.push(Rect { - x: area.x, - y: current_y, - width: area.width, - height: group_height as u16, - }); - - current_y += group_height as u16; - } - - areas -} - fn render_group(frame: &mut Frame, area: Rect, group: &GroupViewState, is_first: bool) { let chunks = Layout::default() .direction(Direction::Vertical) @@ -60,6 +46,10 @@ fn render_group(frame: &mut Frame, area: Rect, group: &GroupViewState, is_first: .constraints([Constraint::Length(1), Constraint::Min(1)]) .split(area); + if chunks[0].height <= 0 || chunks[1].height <= 0 || group.monitors.is_empty() { + return; + } + let group_title = Line::from(vec![ Span::styled(format!("{} ", group.name), title_style()), Span::styled( diff --git a/src/ui/dashboard/model.rs b/src/ui/dashboard/model.rs index 8f24c42..c408c54 100644 --- a/src/ui/dashboard/model.rs +++ b/src/ui/dashboard/model.rs @@ -1,8 +1,11 @@ use crate::core::models::{UnifiedData, UnifiedGroupData}; use crate::data::heartbeat::model::HeartbeatEntry; use crate::i18n::t; +use ratatui::widgets::ScrollbarState; use rayon::prelude::*; +const BORDER_LINES_VIEW: usize = 3; + #[derive(Debug, Clone, PartialEq)] pub enum MonitorStatus { Up, @@ -33,6 +36,7 @@ pub struct DashboardViewState { pub is_loading: bool, pub error_message: Option, pub auto_refresh_interval: u32, + pub scroll_state: ScrollbarState, } impl DashboardViewState { @@ -44,6 +48,7 @@ impl DashboardViewState { is_loading: true, error_message: None, auto_refresh_interval: 300, + scroll_state: ScrollbarState::new(0), } } @@ -57,6 +62,11 @@ impl DashboardViewState { }); } + let content_length = groups + .iter() + .map(|g| g.monitors.len() + BORDER_LINES_VIEW) + .sum::(); + Self { title: data.title, descriptions: data.description, @@ -64,12 +74,17 @@ impl DashboardViewState { is_loading: false, error_message: None, auto_refresh_interval: data.auto_refresh_interval.max(30), + scroll_state: ScrollbarState::new(content_length.saturating_sub(1)), } } pub fn get_all_monitors(&self) -> Vec<&MonitorViewState> { self.groups.iter().flat_map(|g| g.monitors.iter()).collect() } + + pub fn show_vertical_scrollbar(&self, height: u16) -> bool { + height < self.get_all_monitors().len() as u16 + } } fn get_status_history(heartbeats: &[HeartbeatEntry]) -> Vec { @@ -94,36 +109,39 @@ fn get_status_history(heartbeats: &[HeartbeatEntry]) -> Vec { fn add_monitor_view_state(group: UnifiedGroupData) -> Vec { let mut monitors = Vec::with_capacity(group.monitors.len()); - group.monitors.into_par_iter().map(|monitor| { + group + .monitors + .into_par_iter() + .map(|monitor| { + let status_history = get_status_history(&monitor.heartbeats); - let status_history = get_status_history(&monitor.heartbeats); + let status = match monitor.heartbeats.last().map(|h| h.status) { + Some(1) => MonitorStatus::Up, + Some(0) => MonitorStatus::Down, + _ => MonitorStatus::Unknown, + }; - let status = match monitor.heartbeats.last().map(|h| h.status) { - Some(1) => MonitorStatus::Up, - Some(0) => MonitorStatus::Down, - _ => MonitorStatus::Unknown, - }; + let response_time = monitor + .heartbeats + .last() + .and_then(|h| h.ping) + .map(|ms| format!("{}", ms)) + .unwrap_or_else(|| t("unknown").to_string() + " "); - let response_time = monitor - .heartbeats - .last() - .and_then(|h| h.ping) - .map(|ms| format!("{}", ms)) - .unwrap_or_else(|| t("unknown").to_string() + " "); + let uptime_24h = monitor + .uptime_data + .map(|u| u.get_perc_formated()) + .unwrap_or_else(|| t("unknown").to_string()); - let uptime_24h = monitor - .uptime_data - .map(|u| u.get_perc_formated()) - .unwrap_or_else(|| t("unknown").to_string()); - - MonitorViewState { - name: monitor.name, - status, - response_time, - uptime_24h, - status_history, - } - }).collect_into_vec(&mut monitors); + MonitorViewState { + name: monitor.name, + status, + response_time, + uptime_24h, + status_history, + } + }) + .collect_into_vec(&mut monitors); monitors.sort_by_key(|m| m.name.clone());