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, }; 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}, }; use std::collections::HashMap; use std::{ io, time::{Duration, Instant}, }; const INITIAL_INTERVAL: u32 = 300; pub struct App { state: DashboardViewState, terminal: Terminal>, should_quit: bool, last_update: Instant, update_interval: Duration, endpoints: UptimeKumaEndpoints, client: UptimeKumaClient, } 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 render(&mut self) { 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), Constraint::Length(3), ]) .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.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]); }); } 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, _ => {} } } } 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; self.render(); 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(()) } 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 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()?; 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(()) } }