diff --git a/src/cli/commands/backup/inspect.rs b/src/cli/commands/backup/inspect.rs index 5b0035e..ceb68bf 100644 --- a/src/cli/commands/backup/inspect.rs +++ b/src/cli/commands/backup/inspect.rs @@ -19,48 +19,12 @@ pub fn inspect(name: String) { }; let backup_dir_prefix = format!("backup_{name}_"); - let backups = match &backup_job.datastore { - Datastore::FileSystem(store) => store - .list_backups() - .unwrap_or_default() - .into_iter() - .filter_map(|store| { - let dir_name = store.base_path.file_name()?.to_str()?.to_string(); - - if dir_name.starts_with(&backup_dir_prefix) { - Some((dir_name, Datastore::FileSystem(store))) - } else { - None - } - }) - .collect::>(), - - Datastore::S3(store) => store - .list_backups() - .unwrap_or_default() - .into_iter() - .filter_map(|store| { - let dir_name = store.base_path.file_name()?.to_str()?.to_string(); - - if dir_name.starts_with(&backup_dir_prefix) { - Some((dir_name, Datastore::S3(store))) - } else { - None - } - }) - .collect::>(), - }; - - let datastore_type = backup_job.datastore.as_str(); - let datastore_base_path = match &backup_job.datastore { - Datastore::FileSystem(store) => store.base_path.display().to_string(), - Datastore::S3(store) => store.base_path.display().to_string(), - }; + let backups = backup_job.datastore.get_backups(&name); println!("-- {} --", backup_job.display_name); println!("Datastore:"); - println!("\tType: {}", datastore_type); - println!("\tPath: {}", datastore_base_path); + println!("\tType: {}", backup_job.datastore.as_str()); + println!("\tPath: {}", backup_job.datastore.get_base_path()); println!( "Schedule: {:?}", if let Some(schedule) = backup_job.clone().raw_schedule { diff --git a/src/datastores/mod.rs b/src/datastores/mod.rs index 0fe045c..eeb9ac7 100644 --- a/src/datastores/mod.rs +++ b/src/datastores/mod.rs @@ -58,6 +58,13 @@ impl Datastore { } } + pub fn get_base_path(&self) -> String { + match self { + Datastore::FileSystem(store) => store.base_path.display().to_string(), + Datastore::S3(store) => store.base_path.display().to_string(), + } + } + pub fn check_backup_integrity(&self) -> Result { delegate_to_datastore!(self, check_backup_integrity()) } @@ -101,4 +108,40 @@ impl Datastore { Datastore::S3(store) => store.open_read_stream(object_name).await, } } + + pub fn get_backups(&self, backup_identifier: &str) -> Vec<(String, Datastore)> { + let backup_dir_prefix = format!("backup_{backup_identifier}_"); + + match self { + Datastore::FileSystem(store) => store + .list_backups() + .unwrap_or_default() + .into_iter() + .filter_map(|store| { + let dir_name = store.base_path.file_name()?.to_str()?.to_string(); + + if dir_name.starts_with(&backup_dir_prefix) { + Some((dir_name, Datastore::FileSystem(store))) + } else { + None + } + }) + .collect::>(), + + Datastore::S3(store) => store + .list_backups() + .unwrap_or_default() + .into_iter() + .filter_map(|store| { + let dir_name = store.base_path.file_name()?.to_str()?.to_string(); + + if dir_name.starts_with(&backup_dir_prefix) { + Some((dir_name, Datastore::S3(store))) + } else { + None + } + }) + .collect::>(), + } + } } diff --git a/src/ui/app.rs b/src/ui/app.rs index 892d798..b864707 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1,5 +1,4 @@ -use std::io; - +use chrono::DateTime; use ratatui::{ Terminal, crossterm::{ @@ -10,34 +9,45 @@ use ratatui::{ prelude::CrosstermBackend, widgets::ListState, }; +use std::io; +use crate::datastores::Datastore; +use crate::ui::app::CurrentScreen::BackupInspect; +use crate::ui::screens::BackupInspectScreen; +use crate::utils::backup_manager::BackupJob; use crate::{ db::DatabaseConnection, - ui::screens::{DatabasesScreen, HomeItem, HomeScreen, SettingsScreen}, + ui::screens::{BackupsScreen, HomeItem, HomeScreen, SettingsScreen}, + utils::config::Config, }; +#[derive(PartialEq)] pub enum CurrentScreen { Main, - Databases, + Backups, + BackupInspect { backup: BackupJob }, Settings, } #[allow(unused)] pub struct App { should_quit: bool, - current_screen: CurrentScreen, + pub current_screen: CurrentScreen, pub list_state: ListState, + pub config: Config, pub database_connection: DatabaseConnection, } impl App { pub fn new() -> Self { + let config = Config::new(); let mut list_state = ListState::default(); list_state.select_first(); Self { should_quit: false, current_screen: CurrentScreen::Main, list_state, + config, database_connection: DatabaseConnection::new(), } } @@ -45,7 +55,7 @@ impl App { pub async fn run(&mut self) -> io::Result<()> { enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + execute!(stdout, EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; @@ -57,11 +67,16 @@ impl App { eprintln!("Draw error: {}", e); } } - CurrentScreen::Databases => { - if let Err(e) = DatabasesScreen::draw(self, frame) { + CurrentScreen::Backups => { + if let Err(e) = BackupsScreen::draw(self, frame) { eprintln!("Draw error: {}", e); } } + CurrentScreen::BackupInspect { ref backup } => { + if let Err(e) = BackupInspectScreen::draw(self, backup.clone(), frame) { + eprintln!("Draw error: {}", e) + } + } CurrentScreen::Settings => { if let Err(e) = SettingsScreen::draw(self, frame) { eprintln!("Draw error: {}", e); @@ -75,11 +90,7 @@ impl App { } disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; terminal.show_cursor()?; Ok(()) @@ -96,18 +107,40 @@ impl App { } match (&self.current_screen, key.code) { - (CurrentScreen::Main, KeyCode::Down) => self.list_state.select_next(), - (CurrentScreen::Main, KeyCode::Up) => self.list_state.select_previous(), + ( + CurrentScreen::Main | CurrentScreen::Backups | CurrentScreen::BackupInspect { backup: _ }, + KeyCode::Down, + ) => self.list_state.select_next(), + ( + CurrentScreen::Main | CurrentScreen::Backups | CurrentScreen::BackupInspect { backup: _ }, + KeyCode::Up, + ) => self.list_state.select_previous(), (CurrentScreen::Main, KeyCode::Enter) => { let items = HomeScreen::list_items(); if let Some(idx) = self.list_state.selected() { match items[idx] { - HomeItem::Databases => self.set_screen(CurrentScreen::Databases), + HomeItem::Backups => self.set_screen(CurrentScreen::Backups), HomeItem::Settings => self.set_screen(CurrentScreen::Settings), HomeItem::Exit => self.should_quit = true, } } } + (CurrentScreen::Backups, KeyCode::Enter) => { + let items = BackupsScreen::list_items(self); + if let Some(idx) = self.list_state.selected() { + let backup = self + .config + .backups + .get(&format!("backup.{}", items[idx])) + .expect("Backup not found"); + self.set_screen(CurrentScreen::BackupInspect { + backup: backup.clone(), + }) + } + } + (CurrentScreen::Backups | CurrentScreen::Settings, KeyCode::Backspace) => { + self.set_screen(CurrentScreen::Main) + } (_, KeyCode::Char('q') | KeyCode::Esc) => self.should_quit = true, _ => {} } diff --git a/src/ui/screens/backup_inspect.rs b/src/ui/screens/backup_inspect.rs new file mode 100644 index 0000000..c223696 --- /dev/null +++ b/src/ui/screens/backup_inspect.rs @@ -0,0 +1,105 @@ +use crate::datastores::Datastore; +use crate::ui::{app::App, screens::ScreenLayout}; +use crate::utils::backup_manager::BackupJob; +use chrono::DateTime; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::prelude::{Style, Stylize}; +use ratatui::text::Line; +use ratatui::widgets::{Borders, List, ListDirection, Paragraph, Wrap}; +use ratatui::{ + Frame, + widgets::{Block, BorderType}, +}; +use std::io::Result; + +pub struct BackupInspectScreen; + +impl BackupInspectScreen { + pub fn draw(app: &mut App, backup: BackupJob, frame: &mut Frame) -> Result<()> { + ScreenLayout::draw(app, frame, Some(&backup.display_name)); + + let area = frame.area(); + let layout = Layout::default() + .direction(Direction::Horizontal) + .constraints(vec![ + Constraint::Min(25), + Constraint::Length(1), + Constraint::Fill(1), + ]) + .split(area.centered( + Constraint::Length(area.width - 2), + Constraint::Length(area.height - 2), + )); + + let mut lines = vec![ + Line::raw("Datastore:"), + Line::raw(format!(" Type: {}", backup.datastore.as_str())), + Line::raw(format!(" Path: {}", backup.datastore.get_base_path())), + Line::raw(format!( + "Schedule: {}", + if let Some(schedule) = backup.clone().raw_schedule { + schedule + } else { + "disabled".to_string() + } + )), + Line::raw(format!( + "Encryption: {}", + if backup.is_encryption_enabled() { + "enabled" + } else { + "disabled" + } + )), + ]; + + if let Some(next) = backup.clone().get_next_run() { + lines.insert(4, Line::raw(format!("Next run: {:?}", next))) + } + + let paragraph = Paragraph::new(lines).block( + Block::new() + .borders(Borders::ALL) + .border_type(BorderType::Rounded), + ); + + frame.render_widget(paragraph, layout[0]); + + let mut backups_labels = backup + .datastore + .get_backups(&backup.identifier) + .iter() + .map(|(dir_name, datastore)| { + let health_state = datastore.check_backup_integrity().is_ok_and(|res| res); + let timestamp = dir_name + .rsplit("_") + .next() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let date = match DateTime::from_timestamp_secs(timestamp) { + Some(value) => value.to_rfc3339(), + None => "Unknown date".to_string(), + }; + format!( + "\t{} - {} - {}", + dir_name, + date, + if health_state { "Healthy" } else { "Unhealthy" } + ) + }) + .collect::>(); + backups_labels.push("Trigger manual backup".to_string()); + + let list_block = Block::bordered().border_type(BorderType::Rounded); + let list = List::new(backups_labels) + .block(list_block) + .highlight_style(Style::new().reversed()) + .highlight_symbol("▶") + .repeat_highlight_symbol(true) + .direction(ListDirection::TopToBottom); + + frame.render_stateful_widget(list, layout[2], &mut app.list_state); + + Ok(()) + } +} diff --git a/src/ui/screens/backups.rs b/src/ui/screens/backups.rs new file mode 100644 index 0000000..6bc1360 --- /dev/null +++ b/src/ui/screens/backups.rs @@ -0,0 +1,54 @@ +use std::io::Result; + +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::Style, + widgets::{Block, BorderType, List, ListDirection}, +}; + +use crate::ui::{app::App, screens::ScreenLayout}; + +pub struct BackupsScreen; + +impl BackupsScreen { + pub fn draw(app: &mut App, frame: &mut Frame) -> Result<()> { + ScreenLayout::draw(app, frame, Some("Backups")); + + let backups = &app.config.backups; + let backup_names = backups + .values() + .map(|v| v.display_name.clone()) + .collect::>(); + + let area = frame.area(); + + let layout = Layout::default() + .direction(Direction::Horizontal) + .constraints(vec![Constraint::Percentage(25), Constraint::Percentage(75)]) + .split(area.centered( + Constraint::Length(area.width - 2), + Constraint::Length(area.height - 2), + )); + + let list_block = Block::bordered().border_type(BorderType::Rounded); + let list = List::new(backup_names) + .block(list_block) + .highlight_style(Style::new().reversed()) + .highlight_symbol("▶") + .repeat_highlight_symbol(true) + .direction(ListDirection::TopToBottom); + + frame.render_stateful_widget(list, layout[0], &mut app.list_state); + + Ok(()) + } + + pub fn list_items(app: &mut App) -> Vec { + let backups = &app.config.backups; + backups + .values() + .map(|v| v.identifier.clone()) + .collect::>() + } +} diff --git a/src/ui/screens/home.rs b/src/ui/screens/home.rs index f19e5e6..a93add4 100644 --- a/src/ui/screens/home.rs +++ b/src/ui/screens/home.rs @@ -14,7 +14,7 @@ use crate::ui::{ }; pub enum HomeItem { - Databases, + Backups, Settings, Exit, } @@ -22,7 +22,7 @@ pub enum HomeItem { impl From<&HomeItem> for ListItem<'_> { fn from(value: &HomeItem) -> Self { let line = Line::from(match value { - HomeItem::Databases => "Databases", + HomeItem::Backups => "Backups", HomeItem::Settings => "Settings", HomeItem::Exit => "Exit", }) @@ -35,7 +35,7 @@ pub struct HomeScreen; impl HomeScreen { pub fn draw(app: &mut App, frame: &mut Frame) -> Result<()> { - ScreenLayout::draw(frame, None); + ScreenLayout::draw(app, frame, None); let area = frame.area(); @@ -56,7 +56,7 @@ impl HomeScreen { } pub fn list_items() -> &'static [HomeItem] { - static ITEMS: [HomeItem; 3] = [HomeItem::Databases, HomeItem::Settings, HomeItem::Exit]; + static ITEMS: [HomeItem; 3] = [HomeItem::Backups, HomeItem::Settings, HomeItem::Exit]; &ITEMS } } diff --git a/src/ui/screens/layout.rs b/src/ui/screens/layout.rs index 912ee8a..a4cb185 100644 --- a/src/ui/screens/layout.rs +++ b/src/ui/screens/layout.rs @@ -6,11 +6,14 @@ use ratatui::{ widgets::{Block, BorderType}, }; +use crate::ui::app::{App, CurrentScreen}; + pub struct ScreenLayout; impl ScreenLayout { - pub fn draw(frame: &mut Frame, title: Option<&str>) { + pub fn draw(app: &mut App, frame: &mut Frame, title: Option<&str>) { let area = frame.area(); + let display_title = format!( "MongoDB Backup Manager{}", title.map_or("".to_string(), |t| format!(" - {}", t)) @@ -19,9 +22,17 @@ impl ScreenLayout { .border_type(BorderType::Rounded) .title(display_title.bold()) .title_alignment(HorizontalAlignment::Left); - let quit_action = Block::new().title_bottom(Line::from("Esc or q to exit").centered()); + let hint_text = format!( + "Esc or q to exit{}", + if app.current_screen != CurrentScreen::Main { + ", Backspace to go back" + } else { + "" + } + ); + let action_hint = Block::new().title_bottom(Line::from(hint_text).centered()); frame.render_widget(app_title, area); - frame.render_widget(quit_action, area); + frame.render_widget(action_hint, area); } } diff --git a/src/ui/screens/mod.rs b/src/ui/screens/mod.rs index c4e15fe..726ed57 100644 --- a/src/ui/screens/mod.rs +++ b/src/ui/screens/mod.rs @@ -1,10 +1,13 @@ -mod databases; -pub use databases::DatabasesScreen; +mod backups; +pub use backups::BackupsScreen; mod home; pub use home::{HomeItem, HomeScreen}; mod layout; pub use layout::ScreenLayout; +mod backup_inspect; +pub use backup_inspect::BackupInspectScreen; mod settings; + pub use settings::SettingsScreen; use ratatui::{ diff --git a/src/ui/screens/settings.rs b/src/ui/screens/settings.rs index 0567104..ad955c0 100644 --- a/src/ui/screens/settings.rs +++ b/src/ui/screens/settings.rs @@ -9,7 +9,7 @@ pub struct SettingsScreen; #[allow(unused)] impl SettingsScreen { pub fn draw(app: &mut App, frame: &mut Frame) -> Result<()> { - ScreenLayout::draw(frame, Some("Settings")); + ScreenLayout::draw(app, frame, Some("Settings")); Ok(()) }