diff --git a/src/data/mod.rs b/src/data/mod.rs index 3e4a2e7..9a5eb81 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -1,2 +1,5 @@ pub mod heartbeat; pub mod status_page; + +pub use heartbeat::HeartbeatResponse; +pub use status_page::StatusPageResponse; diff --git a/src/data/status_page/mod.rs b/src/data/status_page/mod.rs index c6f36ce..a01983d 100644 --- a/src/data/status_page/mod.rs +++ b/src/data/status_page/mod.rs @@ -1,3 +1,4 @@ pub mod model; pub mod parser; +pub use model::StatusPageResponse; pub use parser::parse_response; diff --git a/src/ui/app.rs b/src/ui/app.rs new file mode 100644 index 0000000..a93a490 --- /dev/null +++ b/src/ui/app.rs @@ -0,0 +1,228 @@ +use crate::api::{UptimeKumaClient, UptimeKumaEndpoints, endpoints}; +use crate::core; +use crate::data::{self, HeartbeatResponse, StatusPageResponse, heartbeat, status_page}; +use crate::i18n::{t, t_with_args}; +use crate::ui::components::{render_header, render_monitor_list}; +use crate::ui::dashboard::model::{DashboardViewState, MonitorStatus}; +use crossterm::{ + event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, + }, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{ + Frame, Terminal, + backend::CrosstermBackend, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style, Stylize}, + text::{Line, Span, Text}, + widgets::{Block, Borders, ListState, Padding, Paragraph}, +}; + +use std::{ + io, + time::{Duration, Instant}, +}; + +pub struct App { + state: DashboardViewState, + terminal: Terminal>, + should_quit: bool, + last_update: Instant, + update_interval: Duration, + endpoints: UptimeKumaEndpoints, + client: UptimeKumaClient, + list_state: ListState, +} + +impl App { + fn setup_terminal(&mut self) -> io::Result<()> { + enable_raw_mode()?; + execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?; + self.terminal.hide_cursor()?; + Ok(()) + } + + fn cleanup_terminal(&mut self) -> io::Result<()> { + disable_raw_mode()?; + execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?; + self.terminal.show_cursor()?; + Ok(()) + } + + fn render_loading(frame: &mut Frame, area: Rect) { + let loading = Paragraph::new(Text::from(Line::from(vec![ + Span::styled(" ", Style::default().fg(Color::Yellow)), + Span::styled(t("loading"), Style::default().add_modifier(Modifier::BOLD)), + ]))) + .alignment(Alignment::Center) + .block( + Block::default() + .title(t("status")) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)) + .padding(Padding::uniform(1)), + ); + + frame.render_widget(loading, area); + } + + fn render_error(frame: &mut Frame, area: Rect, error: &str) { + let error_widget = Paragraph::new(Text::from(Line::from(vec![ + Span::styled("❌ ", Style::default().fg(Color::Red)), + Span::styled(error, Style::default().fg(Color::Red)), + ]))) + .alignment(Alignment::Center) + .block( + Block::default() + .title(t("error")) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Red)) + .padding(Padding::uniform(1)), + ); + frame.render_widget(error_widget, area); + } + + fn render_no_data(frame: &mut Frame, area: Rect) { + let no_data = Paragraph::new(Text::from(Line::from(vec![ + Span::styled("❌ ", Style::default().fg(Color::Blue)), + Span::styled( + t("monitor-not-found"), + Style::default().add_modifier(Modifier::ITALIC), + ), + ]))) + .alignment(Alignment::Center) + .block( + Block::default() + .title(t("error")) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Blue)) + .padding(Padding::uniform(1)), + ); + frame.render_widget(no_data, area); + } + + fn move_selection(&mut self, direction: isize) { + if self.state.monitors.is_empty() { + return; + } + + let current = self.list_state.selected().unwrap_or(0); + let new_index = (current as isize + direction) as usize; + let new_index = new_index.clamp(0, self.state.monitors.len() - 1); + + self.list_state.select(Some(new_index)); + self.state.selected_index = new_index; + } + + fn render(&mut self) { + let mut list_state = self.list_state.clone(); + let _ = self.terminal.draw(|frame| { + let area = frame.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Length(3), Constraint::Min(1)]) + .split(area); + + render_header(frame, chunks[0], &self.state); + + if self.state.is_loading { + 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() { + Self::render_no_data(frame, chunks[1]); + } else { + render_monitor_list(frame, chunks[1], &self.state, &mut list_state); + } + }); + } + + fn handle_events(&mut self) -> io::Result<()> { + if event::poll(Duration::from_millis(50))? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Release { + return Ok(()); + } + + match key.code { + KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true, + KeyCode::Up => self.move_selection(-1), + KeyCode::Down => self.move_selection(1), + _ => {} + } + } + } + Ok(()) + } + + fn update_if_needed(&mut self) -> io::Result<()> { + if self.last_update.elapsed() >= self.update_interval { + if let Err(e) = self.refresh_data() { + self.state.error_message = Some(t("auto-update-failed")); + } + self.last_update = Instant::now(); + } + Ok(()) + } + + fn refresh_data(&mut self) -> io::Result<()> { + self.state.is_loading = true; + self.render(); + + if let Err(e) = self.fetch_and_update_data() { + self.state.error_message = Some(t("update-fail")) + } + + self.state.is_loading = false; + Ok(()) + } + + pub fn new(endpoints: UptimeKumaEndpoints) -> io::Result { + let backend = CrosstermBackend::new(io::stdout()); + let mut terminal = Terminal::new(backend)?; + terminal.hide_cursor()?; + + let state = DashboardViewState::new(); + let mut list_state = ListState::default(); + list_state.select(Some(0)); + + Ok(Self { + state, + terminal, + should_quit: false, + last_update: Instant::now(), + update_interval: Duration::from_secs(30), + endpoints, + client: UptimeKumaClient::new(), + list_state: list_state, + }) + } + + fn fetch_and_update_data(&mut self) -> anyhow::Result<()> { + let heartbeat_data = self.client.fetch_heartbeat(&self.endpoints)?; + let status_page_data = self.client.fetch_status_page(&self.endpoints)?; + let unified_data = core::unify_data(&status_page_data, &heartbeat_data); + self.state = DashboardViewState::from_unified_data(unified_data); + Ok(()) + } + + fn load_initial_data(&mut self) -> io::Result<()> { + self.refresh_data() + } + + pub fn run(&mut self) -> io::Result<()> { + self.setup_terminal()?; + self.load_initial_data()?; + + while !self.should_quit { + self.render(); + self.handle_events()?; + self.update_if_needed()?; + } + self.cleanup_terminal()?; + Ok(()) + } +} diff --git a/src/ui/components/header.rs b/src/ui/components/header.rs new file mode 100644 index 0000000..b42b92c --- /dev/null +++ b/src/ui/components/header.rs @@ -0,0 +1,30 @@ +use crate::i18n::t; +use crate::ui::dashboard::model::DashboardViewState; +use ratatui::{ + Frame, + layout::{Alignment, Rect}, + style::{Color, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; + +pub fn render_header(frame: &mut Frame, area: Rect, state: &DashboardViewState) { + let title = if state.is_loading { + t("loading").to_string() + } else { + format!("{} - {}", t("dashboard-heaser"), state.title) + }; + + let header = Paragraph::new(Line::from(vec![ + Span::styled("📈 ", Style::default().fg(Color::Cyan)), + Span::styled(title, Style::default().fg(Color::White).bold()), + ])) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .style(Style::default().fg(Color::Blue)), + ); + + frame.render_widget(header, area); +} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs new file mode 100644 index 0000000..db85d66 --- /dev/null +++ b/src/ui/components/mod.rs @@ -0,0 +1,5 @@ +pub mod header; +pub mod monitor_list; + +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 new file mode 100644 index 0000000..8e47dfe --- /dev/null +++ b/src/ui/components/monitor_list.rs @@ -0,0 +1,71 @@ +use crate::i18n::t; +use crate::ui::dashboard::model::{DashboardViewState, MonitorStatus}; +use ratatui::style::Modifier; +use ratatui::widgets::{List, ListItem, ListState}; +use ratatui::{ + Frame, + layout::{Alignment, Rect}, + style::{Color, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; + +pub fn render_monitor_list( + frame: &mut Frame, + area: Rect, + state: &DashboardViewState, + list_state: &mut ListState, +) { + let block = Block::default() + .title(t("monitors")) + .borders(Borders::ALL) + .style(Style::default().fg(Color::Blue)); + + let items: Vec = state + .monitors + .iter() + .map(|monitor| { + let status_icon = match monitor.status { + MonitorStatus::Up => "✅", + MonitorStatus::Down => "❌", + MonitorStatus::Unknown => "?", + }; + + let status_color = match monitor.status { + MonitorStatus::Up => Color::Green, + MonitorStatus::Down => Color::Red, + MonitorStatus::Unknown => Color::Yellow, + }; + + let line = Line::from(vec![ + Span::styled( + format!("{} ", status_icon), + Style::default().fg(status_color), + ), + Span::styled( + &monitor.name, + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" | {}ms", monitor.response_time), + Style::default().fg(Color::Cyan), + ), + Span::styled( + format!(" | {}%", monitor.uptime_24h), + Style::default().fg(Color::Magenta), + ), + ]); + + ListItem::new(line) + }) + .collect(); + + let list = List::new(items) + .block(block) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .highlight_symbol(">> "); + + frame.render_stateful_widget(list, area, list_state); +} diff --git a/src/ui/dashboard/model.rs b/src/ui/dashboard/model.rs index 72a3a30..fb11b19 100644 --- a/src/ui/dashboard/model.rs +++ b/src/ui/dashboard/model.rs @@ -95,13 +95,4 @@ impl DashboardViewState { pub fn get_selected_monitor(&self) -> Option<&MonitorViewState> { self.monitors.get(self.selected_index) } - - pub fn move_selection(&mut self, direction: isize) { - if self.monitors.is_empty() { - return; - } - - let new_index = (self.selected_index as isize + direction) as usize; - self.selected_index = new_index.clamp(0, self.monitors.len() - 1); - } } diff --git a/src/ui/events.rs b/src/ui/events.rs new file mode 100644 index 0000000..b22bb6d --- /dev/null +++ b/src/ui/events.rs @@ -0,0 +1,6 @@ +#[derive(Debug)] +pub enum AppEvent { + KeyPress(char), + RefreshData, + Quit, +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d87ba85..ef490af 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1 +1,6 @@ +pub mod app; +pub mod components; pub mod dashboard; +pub mod events; + +pub use app::App;