Testing ratatui
This commit is contained in:
parent
28483fe165
commit
843d05bec9
9 changed files with 349 additions and 9 deletions
|
|
@ -1,2 +1,5 @@
|
||||||
pub mod heartbeat;
|
pub mod heartbeat;
|
||||||
pub mod status_page;
|
pub mod status_page;
|
||||||
|
|
||||||
|
pub use heartbeat::HeartbeatResponse;
|
||||||
|
pub use status_page::StatusPageResponse;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod parser;
|
pub mod parser;
|
||||||
|
pub use model::StatusPageResponse;
|
||||||
pub use parser::parse_response;
|
pub use parser::parse_response;
|
||||||
|
|
|
||||||
228
src/ui/app.rs
Normal file
228
src/ui/app.rs
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
use crate::api::{UptimeKumaClient, UptimeKumaEndpoints, endpoints};
|
||||||
|
use crate::core;
|
||||||
|
use crate::data::{self, HeartbeatResponse, StatusPageResponse, heartbeat, status_page};
|
||||||
|
use crate::i18n::{t, t_with_args};
|
||||||
|
use crate::ui::components::{render_header, render_monitor_list};
|
||||||
|
use crate::ui::dashboard::model::{DashboardViewState, MonitorStatus};
|
||||||
|
use crossterm::{
|
||||||
|
event::{
|
||||||
|
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
|
||||||
|
},
|
||||||
|
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, Stylize},
|
||||||
|
text::{Line, Span, Text},
|
||||||
|
widgets::{Block, Borders, ListState, Padding, Paragraph},
|
||||||
|
};
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
io,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
state: DashboardViewState,
|
||||||
|
terminal: Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
|
should_quit: bool,
|
||||||
|
last_update: Instant,
|
||||||
|
update_interval: Duration,
|
||||||
|
endpoints: UptimeKumaEndpoints,
|
||||||
|
client: UptimeKumaClient,
|
||||||
|
list_state: ListState,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Self> {
|
||||||
|
let backend = CrosstermBackend::new(io::stdout());
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
terminal.hide_cursor()?;
|
||||||
|
|
||||||
|
let state = DashboardViewState::new();
|
||||||
|
let mut list_state = ListState::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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/ui/components/header.rs
Normal file
30
src/ui/components/header.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
use crate::i18n::t;
|
||||||
|
use crate::ui::dashboard::model::DashboardViewState;
|
||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::{Alignment, Rect},
|
||||||
|
style::{Color, Style, Stylize},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn render_header(frame: &mut Frame, area: Rect, state: &DashboardViewState) {
|
||||||
|
let title = if state.is_loading {
|
||||||
|
t("loading").to_string()
|
||||||
|
} else {
|
||||||
|
format!("{} - {}", t("dashboard-heaser"), state.title)
|
||||||
|
};
|
||||||
|
|
||||||
|
let header = Paragraph::new(Line::from(vec![
|
||||||
|
Span::styled("📈 ", Style::default().fg(Color::Cyan)),
|
||||||
|
Span::styled(title, Style::default().fg(Color::White).bold()),
|
||||||
|
]))
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.style(Style::default().fg(Color::Blue)),
|
||||||
|
);
|
||||||
|
|
||||||
|
frame.render_widget(header, area);
|
||||||
|
}
|
||||||
5
src/ui/components/mod.rs
Normal file
5
src/ui/components/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
pub mod header;
|
||||||
|
pub mod monitor_list;
|
||||||
|
|
||||||
|
pub use header::render_header;
|
||||||
|
pub use monitor_list::render_monitor_list;
|
||||||
71
src/ui/components/monitor_list.rs
Normal file
71
src/ui/components/monitor_list.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
use crate::i18n::t;
|
||||||
|
use crate::ui::dashboard::model::{DashboardViewState, MonitorStatus};
|
||||||
|
use ratatui::style::Modifier;
|
||||||
|
use ratatui::widgets::{List, ListItem, ListState};
|
||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::{Alignment, Rect},
|
||||||
|
style::{Color, Style, Stylize},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn render_monitor_list(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
state: &DashboardViewState,
|
||||||
|
list_state: &mut ListState,
|
||||||
|
) {
|
||||||
|
let block = Block::default()
|
||||||
|
.title(t("monitors"))
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.style(Style::default().fg(Color::Blue));
|
||||||
|
|
||||||
|
let items: Vec<ListItem> = state
|
||||||
|
.monitors
|
||||||
|
.iter()
|
||||||
|
.map(|monitor| {
|
||||||
|
let status_icon = match monitor.status {
|
||||||
|
MonitorStatus::Up => "✅",
|
||||||
|
MonitorStatus::Down => "❌",
|
||||||
|
MonitorStatus::Unknown => "?",
|
||||||
|
};
|
||||||
|
|
||||||
|
let status_color = match monitor.status {
|
||||||
|
MonitorStatus::Up => Color::Green,
|
||||||
|
MonitorStatus::Down => Color::Red,
|
||||||
|
MonitorStatus::Unknown => Color::Yellow,
|
||||||
|
};
|
||||||
|
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("{} ", status_icon),
|
||||||
|
Style::default().fg(status_color),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
&monitor.name,
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::White)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
format!(" | {}ms", monitor.response_time),
|
||||||
|
Style::default().fg(Color::Cyan),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
format!(" | {}%", monitor.uptime_24h),
|
||||||
|
Style::default().fg(Color::Magenta),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
ListItem::new(line)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let list = List::new(items)
|
||||||
|
.block(block)
|
||||||
|
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
|
||||||
|
.highlight_symbol(">> ");
|
||||||
|
|
||||||
|
frame.render_stateful_widget(list, area, list_state);
|
||||||
|
}
|
||||||
|
|
@ -95,13 +95,4 @@ impl DashboardViewState {
|
||||||
pub fn get_selected_monitor(&self) -> Option<&MonitorViewState> {
|
pub fn get_selected_monitor(&self) -> Option<&MonitorViewState> {
|
||||||
self.monitors.get(self.selected_index)
|
self.monitors.get(self.selected_index)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn move_selection(&mut self, direction: isize) {
|
|
||||||
if self.monitors.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let new_index = (self.selected_index as isize + direction) as usize;
|
|
||||||
self.selected_index = new_index.clamp(0, self.monitors.len() - 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
6
src/ui/events.rs
Normal file
6
src/ui/events.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum AppEvent {
|
||||||
|
KeyPress(char),
|
||||||
|
RefreshData,
|
||||||
|
Quit,
|
||||||
|
}
|
||||||
|
|
@ -1 +1,6 @@
|
||||||
|
pub mod app;
|
||||||
|
pub mod components;
|
||||||
pub mod dashboard;
|
pub mod dashboard;
|
||||||
|
pub mod events;
|
||||||
|
|
||||||
|
pub use app::App;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue