289 lines
9.6 KiB
Rust
289 lines
9.6 KiB
Rust
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<CrosstermBackend<io::Stdout>>,
|
|
should_quit: bool,
|
|
last_update: Instant,
|
|
update_interval: Duration,
|
|
endpoints: UptimeKumaEndpoints,
|
|
client: UptimeKumaClient,
|
|
}
|
|
|
|
impl App {
|
|
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 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<Rect>, 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(())
|
|
}
|
|
}
|