uptime-kuma-dashboard/src/ui/app.rs
2025-12-29 06:50:18 -04:00

273 lines
8.8 KiB
Rust

use crate::api::{UptimeKumaClient, UptimeKumaEndpoints};
use crate::core;
use crate::data::{heartbeat::HeartbeatResponse, status_page::model::StatusPageResponse};
use crate::i18n::{t, t_with_args};
use crate::ui::{
components::{render_footer, render_header, render_monitor_list},
dashboard::model::DashboardViewState,
};
use anyhow::Result;
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,
io,
sync::mpsc,
thread,
time::{Duration, Instant},
};
const INITIAL_INTERVAL: u32 = 300;
enum FetchResult {
Heartbeat(Result<HeartbeatResponse>),
StatusPage(Result<StatusPageResponse>),
}
pub struct App {
state: DashboardViewState,
terminal: Terminal<CrosstermBackend<io::Stdout>>,
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);
}
let seconds_until_update = self
.update_interval
.checked_sub(self.last_update.elapsed())
.map(|d| d.as_secs() as u64)
.unwrap_or(30);
render_footer(frame, chunks[2], seconds_until_update);
});
}
fn handle_events(&mut self) -> io::Result<()> {
let timeout = Duration::from_secs(1);
if event::poll(timeout)? {
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<Self> {
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 (tx, rx) = mpsc::channel();
let heartbeat_client = self.client.clone();
let heartbeat_endpoints = self.endpoints.clone();
let tx_clone = tx.clone();
thread::spawn(move || {
let result = heartbeat_client.fetch_heartbeat(&heartbeat_endpoints);
tx.send(FetchResult::Heartbeat(result)).unwrap();
});
let status_page_client = self.client.clone();
let status_page_endpoints = self.endpoints.clone();
thread::spawn(move || {
let result = status_page_client.fetch_status_page(&status_page_endpoints);
tx_clone.send(FetchResult::StatusPage(result)).unwrap();
});
let mut heartbeat_result = None;
let mut status_page_result = None;
for _ in 0..2 {
match rx.recv()? {
FetchResult::Heartbeat(result) => heartbeat_result = Some(result?),
FetchResult::StatusPage(result) => status_page_result = Some(result?),
}
}
let heartbeat_data = heartbeat_result.unwrap();
let status_page_data = status_page_result.unwrap();
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(())
}
}