diff --git a/Cargo.lock b/Cargo.lock index baac930..3e1087e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -79,6 +88,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "base64" version = "0.22.1" @@ -150,6 +165,19 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.5.53" @@ -782,6 +810,30 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -1099,6 +1151,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1983,6 +2044,7 @@ name = "uptime-kuma-dashboard" version = "0.1.0" dependencies = [ "anyhow", + "chrono", "clap", "crossterm 0.29.0", "fluent-templates", @@ -2159,6 +2221,41 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 9e79aec..8dcdbeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,3 +16,4 @@ once_cell = "1.19" url = "2.5.7" ratatui = "0.29.0" crossterm = "0.29.0" +chrono = "0.4.42" diff --git a/src/core/data.rs b/src/core/data.rs index 8c8e069..e77ce93 100644 --- a/src/core/data.rs +++ b/src/core/data.rs @@ -1,11 +1,12 @@ -use crate::core::models::{UnifiedData, UnifiedMonitorData}; +use crate::core::models::{UnifiedData, UnifiedGroupData, UnifiedMonitorData}; use crate::data::heartbeat::model::HeartbeatResponse; use crate::data::status_page::model::StatusPageResponse; pub fn unify_data(status_page: &StatusPageResponse, heartbeat: &HeartbeatResponse) -> UnifiedData { - let mut monitors = Vec::new(); + let mut groups = Vec::new(); for group in &status_page.public_group_list { + let mut monitors = Vec::new(); for monitor_info in &group.monitor_list { let uptime_data = heartbeat.get_uptime(monitor_info.id, 24).cloned(); @@ -22,14 +23,21 @@ pub fn unify_data(status_page: &StatusPageResponse, heartbeat: &HeartbeatRespons uptime_data, }); } + + monitors.sort_by_key(|m| m.monitor_info.name.clone()); + + groups.push(UnifiedGroupData { + group_info: group.clone(), + monitors, + }); } - monitors.sort_by_key(|m| m.monitor_info.id); + groups.sort_by_key(|g| g.group_info.weight); UnifiedData { title: status_page.config.title.clone(), description: status_page.config.description.clone(), - monitors, - audo_refresh_interval: status_page.config.auto_refresh_interval, + groups, + auto_refresh_interval: status_page.config.auto_refresh_interval, } } diff --git a/src/core/models.rs b/src/core/models.rs index e8ca173..797b806 100644 --- a/src/core/models.rs +++ b/src/core/models.rs @@ -1,5 +1,5 @@ use crate::data::heartbeat::model::{HeartbeatEntry, UptimeData}; -use crate::data::status_page::model::MonitorInfo; +use crate::data::status_page::model::{MonitorInfo, StatusPageGroup}; #[derive(Debug, Clone)] pub struct UnifiedMonitorData { @@ -12,6 +12,12 @@ pub struct UnifiedMonitorData { pub struct UnifiedData { pub title: String, pub description: Option, - pub monitors: Vec, - pub audo_refresh_interval: u32, + pub auto_refresh_interval: u32, + pub groups: Vec, +} + +#[derive(Debug, Clone)] +pub struct UnifiedGroupData { + pub group_info: StatusPageGroup, + pub monitors: Vec, } diff --git a/src/ui/app.rs b/src/ui/app.rs index ae1b7f8..a108405 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1,6 +1,7 @@ use crate::api::{UptimeKumaClient, UptimeKumaEndpoints}; use crate::core; use crate::i18n::{t, t_with_args}; +use crate::ui::components::render_footer; use crate::ui::{ components::{render_header, render_monitor_list}, dashboard::model::DashboardViewState, @@ -110,7 +111,11 @@ impl App { let chunks = Layout::default() .direction(Direction::Vertical) .margin(1) - .constraints([Constraint::Length(3), Constraint::Min(1)]) + .constraints([ + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(1), + ]) .split(area); render_header(frame, chunks[0], &self.state); @@ -119,11 +124,12 @@ impl App { Self::render_loading(frame, chunks[1]); } else if let Some(error) = &self.state.error_message { Self::render_error(frame, chunks[1], error); - } else if self.state.monitors.is_empty() { + } else if self.state.groups.is_empty() || self.state.get_all_monitors().is_empty() { Self::render_no_data(frame, chunks[1]); } else { render_monitor_list(frame, chunks[1], &self.state); } + render_footer(frame, chunks[2]); }); } diff --git a/src/ui/components/footer.rs b/src/ui/components/footer.rs new file mode 100644 index 0000000..43f3896 --- /dev/null +++ b/src/ui/components/footer.rs @@ -0,0 +1,27 @@ +use ratatui::{ + Frame, + layout::{Alignment, Rect}, + style::{Color, Modifier, Style}, + text::Span, + widgets::{Block, Borders, Paragraph}, +}; + +use chrono::Local; + +pub fn render_footer(frame: &mut Frame, area: Rect) { + let now = Local::now(); + let datatime_str = now.format("%Y-%m-%d %H:%M:%S").to_string(); + + let footer = Paragraph::new(Span::styled( + datatime_str, + Style::default().fg(Color::Gray).add_modifier(Modifier::DIM), + )) + .block( + Block::default() + .borders(Borders::TOP) + .border_style(Style::default().fg(Color::Blue)), + ) + .alignment(Alignment::Center); + + frame.render_widget(footer, area); +} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index db85d66..4a03454 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -1,5 +1,7 @@ +pub mod footer; pub mod header; pub mod monitor_list; +pub use footer::render_footer; pub use header::render_header; pub use monitor_list::render_monitor_list; diff --git a/src/ui/components/monitor_list.rs b/src/ui/components/monitor_list.rs index 3b0b7c8..b6006a4 100644 --- a/src/ui/components/monitor_list.rs +++ b/src/ui/components/monitor_list.rs @@ -1,9 +1,12 @@ +use std::cmp::min; + use crate::i18n::t; -use crate::ui::dashboard::model::DashboardViewState; +use crate::ui::dashboard::model::{DashboardViewState, GroupViewState}; use crate::ui::dashboard::{MonitorStatus, MonitorViewState}; +use ratatui::layout::Layout; use ratatui::{ Frame, - layout::{Constraint, Rect}, + layout::{Alignment, Constraint, Direction, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Row, Table}, @@ -13,42 +16,110 @@ 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 block = Block::default() - .title(t("monitors")) - .borders(Borders::ALL) - .style(Style::default().fg(Color::Blue)); + let group_areas = layout_groups(area, state.groups.len()); - let header = vec![ + for (i, group) in state.groups.iter().enumerate() { + if i < group_areas.len() { + render_group(frame, group_areas[i], group, i == 0); + } + } +} + +fn layout_groups(area: Rect, group_count: usize) -> Vec { + if group_count == 0 { + return vec![]; + } + + let height_per_group = (area.height as usize / group_count).max(3); + let mut current_y = area.y; + let mut areas = Vec::new(); + + for _ in 0..group_count { + if current_y + height_per_group as u16 > area.y + area.height { + break; + } + + areas.push(Rect { + x: area.x, + y: current_y, + width: area.width, + height: height_per_group as u16, + }); + + current_y += height_per_group as u16; + } + areas +} + +fn render_group(frame: &mut Frame, area: Rect, group: &GroupViewState, is_first: bool) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(0) + .constraints([Constraint::Length(1), Constraint::Min(1)]) + .split(area); + + let group_title = Line::from(vec![ + Span::styled(format!("{} ", group.name), title_style()), + Span::styled( + format!("({})", group.monitors.len()), + Style::default().fg(Color::Gray), + ), + ]); + + let title_block = Block::default() + .borders(if is_first { + Borders::TOP | Borders::LEFT | Borders::RIGHT + } else { + Borders::ALL + }) + .border_style(Style::default().fg(Color::Blue)) + .title(group_title) + .title_alignment(Alignment::Left); + + frame.render_widget(title_block, chunks[0]); + + if !group.monitors.is_empty() { + render_monitor_table(frame, chunks[1], &group.monitors); + } +} + +fn render_monitor_table(frame: &mut Frame, area: Rect, monitors: &Vec) { + let max_items = area.height as usize; + let items_to_show = min(monitors.len(), max_items); + + let header_cells = vec![ "".to_string(), t("monitor"), t("Response"), t("uptime"), t("history"), ]; - let constraints = vec![ + + let header = Row::new(header_cells).style(title_style()).height(1); + + let rows: Vec = monitors + .iter() + .take(items_to_show) + .map(|monitor| create_monitor_item(monitor)) + .collect(); + + let widths = vec![ Constraint::Length(3), Constraint::Length(MAX_NAME_LENGTH as u16), Constraint::Length(10), Constraint::Length(10), - Constraint::Length(STATUS_LINE_LENGTH as u16 + 8), + Constraint::Length(STATUS_LINE_LENGTH as u16), ]; - let available_height = area.height.saturating_sub(2); - let visible_items = state.monitors.len().min(available_height as usize); - - let rows: Vec = state - .monitors - .iter() - .take(visible_items) - .map(|m| create_monitor_item(m)) - .collect(); - - let table = Table::new(rows, constraints) - .header(Row::new(header)) - .block(block) + let table = Table::new(rows, widths) + .header(header) + .block( + Block::default() + .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM) + .border_style(Style::default().fg(Color::Blue)), + ) .column_spacing(1) - .highlight_symbol(">> ") - .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + .style(Style::default()); frame.render_widget(table, area); } @@ -126,3 +197,9 @@ fn create_status_line_spans(status_history: &[MonitorStatus]) -> Line<'_> { } Line::from(spans) } + +fn title_style() -> Style { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) +} diff --git a/src/ui/dashboard/model.rs b/src/ui/dashboard/model.rs index 3e1ec7e..24da417 100644 --- a/src/ui/dashboard/model.rs +++ b/src/ui/dashboard/model.rs @@ -1,4 +1,4 @@ -use crate::core::models::UnifiedData; +use crate::core::models::{UnifiedData, UnifiedGroupData}; use crate::data::heartbeat::model::HeartbeatEntry; use crate::i18n::t; @@ -21,11 +21,17 @@ pub struct MonitorViewState { pub status_history: Vec, } +#[derive(Debug, Clone)] +pub struct GroupViewState { + pub name: String, + pub monitors: Vec, +} + #[derive(Debug, Clone)] pub struct DashboardViewState { pub title: String, pub descriptions: Option, - pub monitors: Vec, + pub groups: Vec, pub is_loading: bool, pub error_message: Option, pub auto_refresh_interval: u32, @@ -36,7 +42,7 @@ impl DashboardViewState { Self { title: t("loading").to_string(), descriptions: None, - monitors: Vec::new(), + groups: Vec::new(), is_loading: true, error_message: None, auto_refresh_interval: 300, @@ -44,58 +50,28 @@ impl DashboardViewState { } pub fn from_unified_data(data: UnifiedData) -> Self { - let mut monitors = Vec::new(); + let mut groups = Vec::new(); - for monitor in data.monitors { - 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 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 last_check = monitor - .heartbeats - .last() - .map(|h| h.time.clone()) - .unwrap_or_else(|| t("never").to_string()); - - monitors.push(MonitorViewState { - id: monitor.monitor_info.id, - name: monitor.monitor_info.name, - group_name: "Services".to_string(), - status, - response_time, - uptime_24h, - last_check, - status_history, + for group in data.groups { + groups.push(GroupViewState { + name: group.group_info.name.clone(), + monitors: add_monitor_view_state(group), }); } - monitors.sort_by_key(|m| m.name.clone()); - Self { title: data.title, descriptions: data.description, - monitors, + groups, is_loading: false, error_message: None, - auto_refresh_interval: data.audo_refresh_interval, + auto_refresh_interval: data.auto_refresh_interval.max(30), } } + + pub fn get_all_monitors(&self) -> Vec<&MonitorViewState> { + self.groups.iter().flat_map(|g| g.monitors.iter()).collect() + } } fn get_status_history(heartbeats: &[HeartbeatEntry]) -> Vec { @@ -117,3 +93,50 @@ fn get_status_history(heartbeats: &[HeartbeatEntry]) -> Vec { history.reverse(); history } + +fn add_monitor_view_state(group: UnifiedGroupData) -> Vec { + let mut monitors = Vec::new(); + + for monitor in group.monitors { + 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 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 last_check = monitor + .heartbeats + .last() + .map(|h| h.time.clone()) + .unwrap_or_else(|| t("never").to_string()); + + monitors.push(MonitorViewState { + id: monitor.monitor_info.id, + name: monitor.monitor_info.name, + group_name: group.group_info.name.clone(), + status, + response_time, + uptime_24h, + last_check, + status_history, + }); + } + + monitors.sort_by_key(|m| m.name.clone()); + + monitors +}