use crate::api::{UptimeKumaClient, UptimeKumaEndpoints}; use crate::core; use crate::i18n::t; 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, TableState, 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: TableState, } 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 = TableState::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(()) } }