use crate::api::{UptimeKumaClient, UptimeKumaEndpoints}; use crate::core; use crate::i18n::{t, t_with_args}; use crate::ui::{ components::{render_footer, render_header, render_monitor_list}, dashboard::model::DashboardViewState, }; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, 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}, text::{Line, Span, Text}, widgets::{Block, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation}, }; use std::{ collections::HashMap, io, time::{Duration, Instant}, }; const INITIAL_INTERVAL: u32 = 300; const MAIN_LAYOUT_WITH_SCROLLBAR: [Constraint; 2] = [Constraint::Min(1), Constraint::Length(1)]; const MAIN_LAYOUT_WITHOUT_SCROLLBAR: [Constraint; 1] = [Constraint::Min(1)]; pub struct App { state: DashboardViewState, terminal: Terminal>, should_quit: bool, last_update: Instant, update_interval: Duration, endpoints: UptimeKumaEndpoints, client: UptimeKumaClient, } impl App { 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 initial_interval = Duration::from_secs(INITIAL_INTERVAL as u64); Ok(Self { state, terminal, should_quit: false, last_update: Instant::now(), update_interval: initial_interval, endpoints, client: UptimeKumaClient::new(), }) } 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 render(&mut self) { let _ = self.terminal.draw(|frame| { let area = frame.area(); const HEADER_HEIGHT: u16 = 3; const FOOTER_HEIGHT: u16 = 3; let max_content_height = area.height.saturating_sub(HEADER_HEIGHT + FOOTER_HEIGHT); let chunks = Layout::default() .direction(Direction::Vertical) .margin(1) .constraints([ Constraint::Length(HEADER_HEIGHT), Constraint::Length(max_content_height.max(1)), Constraint::Length(FOOTER_HEIGHT), ]) .split(area); render_header(frame, chunks[0], &self.state); let main_constraint = if self.state.show_vertical_scrollbar(chunks[1].height) { &MAIN_LAYOUT_WITH_SCROLLBAR[..] } else { &MAIN_LAYOUT_WITHOUT_SCROLLBAR[..] }; let main_chunks = Layout::default() .direction(Direction::Horizontal) .constraints(main_constraint) .split(chunks[1]); if self.state.is_loading { Self::render_loading(frame, main_chunks[0]); } else if let Some(error) = &self.state.error_message { 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, main_chunks[0]); } else { Self::render_main(frame, main_chunks.to_vec(), &mut self.state); } let seconds_until_update = self .update_interval .checked_sub(self.last_update.elapsed()) .map(|d| d.as_secs()) .unwrap_or(30); render_footer(frame, chunks[2], seconds_until_update); }); } fn render_main(frame: &mut Frame, area: Vec, state: &mut DashboardViewState) { render_monitor_list(frame, area[0], state); if !state.show_vertical_scrollbar(area[0].height) { return; } if area.len() <= 1 { dbg!(area[0].height); return; } let max_scroll = state .get_total_lenght() .saturating_sub(area[0].height as usize) .saturating_add(1); state.scroll_state = state .scroll_state .content_length(max_scroll.max(1)) .viewport_content_length(area[0].height as usize); 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); if event::poll(timeout)? && 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 | KeyCode::Char('k') => self.state.scroll_state.prev(), KeyCode::Down | KeyCode::Char('j') => self.state.scroll_state.next(), KeyCode::Home => self.state.scroll_state.first(), KeyCode::End => self.state.scroll_state.last(), _ => {} } } Ok(()) } fn update_if_needed(&mut self) -> io::Result<()> { if self.last_update.elapsed() >= self.update_interval { if let Err(e) = self.refresh_data() { let mut error = HashMap::new(); error.insert("error", e.to_string()); self.state.error_message = Some(t_with_args("auto-update-failed", &error)); } self.last_update = Instant::now(); } Ok(()) } fn refresh_data(&mut self) -> io::Result<()> { self.state.is_loading = true; match self.fetch_and_update_data() { Ok(()) => { self.update_interval = Duration::from_secs(self.state.auto_refresh_interval as u64) } Err(e) => { let mut error = HashMap::new(); error.insert("error", e.to_string()); self.state.error_message = Some(t_with_args("update-fail", &error)); } } self.state.is_loading = false; Ok(()) } fn fetch_and_update_data(&mut self) -> anyhow::Result<()> { let heartbeat_result = self.client.fetch_heartbeat(&self.endpoints)?; let status_page_result = self.client.fetch_status_page(&self.endpoints)?; let unified_data = core::unify_data(&status_page_result, &heartbeat_result); self.state = DashboardViewState::from_unified_data(unified_data); Ok(()) } fn load_initial_data(&mut self) -> io::Result<()> { self.refresh_data()?; self.update_interval = Duration::from_secs(self.state.auto_refresh_interval as u64); Ok(()) } 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(()) } }