diff --git a/README.md b/README.md index a27118c..3af2402 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # clever Clever is a simple cli application to view *.clef logs. Written in Rust & powered by Ratatui -![clever](./images/clever.png) +![clever](./images/clever.gif) # Installation diff --git a/images/clever.gif b/images/clever.gif new file mode 100644 index 0000000..4830be0 Binary files /dev/null and b/images/clever.gif differ diff --git a/src/app.rs b/src/app.rs index 2572fe2..6c2b93f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,17 +1,30 @@ -use ratatui::widgets::{Row, TableState}; +use crate::{clef::ClefLine, event_log_level::EventLogLevel}; +use ratatui::widgets::{ListState, Row, TableState}; -use crate::clef::ClefLine; +#[derive(Debug)] +pub enum AppState { + FILTERING, + ITERATING, +} + +impl Default for AppState { + fn default() -> Self { + AppState::ITERATING + } +} #[derive(Debug, Default)] pub struct App<'a> { pub should_quit: bool, pub counter: u8, pub lines: Vec>, pub rows: Vec>, - pub table_state: TableState, - pub file_path: String + pub event_table_state: TableState, + pub filter_list_state: ListState, + pub file_path: String, + pub event_types: Vec, + pub app_state: AppState, } - impl<'a> App<'a> { pub fn new() -> Self { Self::default() @@ -20,38 +33,73 @@ impl<'a> App<'a> { pub fn tick(&self) {} pub fn load_lines(&mut self, lines: &Vec) { - self.lines = Self::create_cells_from_line(lines); + self.lines = self.create_cells_from_line(lines); } - fn create_cells_from_line(lines: &Vec) -> Vec> { + fn create_cells_from_line(&mut self, lines: &Vec) -> Vec> { let mut clef_lines: Vec> = vec![]; - + for line in lines { clef_lines.push(ClefLine::new(line).unwrap()) } + self.get_event_types(&clef_lines); clef_lines } + pub fn get_event_types(&mut self, events: &Vec) { + for event in events { + if !self.event_types.iter().any(|t| t.value == event.level) { + self.event_types.push(EventLogLevel { + selected: true, + value: event.level.to_string(), + }); + } + } + self.event_types + .iter() + .for_each(|v| println!("{}", v.value)); + } + pub fn quit(&mut self) { self.should_quit = true; } pub fn move_row_up(&mut self, range: usize) { - if let Some(selected) = self.table_state.selected() { - if selected >= range { - self.table_state.select(Some(selected - range)); + if let Some(selected) = self.event_table_state.selected() { + if selected >= range +1 { + self.event_table_state.select(Some(selected - range)); } else { - self.table_state.select(Some(self.lines.len() - 1)); + self.event_table_state.select(Some(self.lines.len() - 1)); } } } pub fn move_row_down(&mut self, range: usize) { - if let Some(selected) = self.table_state.selected() { + if let Some(selected) = self.event_table_state.selected() { if selected < self.lines.len() - range { - self.table_state.select(Some(selected + range)); + self.event_table_state.select(Some(selected + range)); + } else { + self.event_table_state.select(Some(0)); + } + } + } + + pub fn move_list_up(&mut self) { + if let Some(selected) = self.filter_list_state.selected() { + if selected >= 1 { + self.filter_list_state.select(Some(selected - 1)); + } else { + self.filter_list_state.select(Some(0)); + } + } + } + + pub fn move_list_down(&mut self) { + if let Some(selected) = self.filter_list_state.selected() { + if selected < self.event_types.len() { + self.filter_list_state.select(Some(selected + 1)); } else { - self.table_state.select(Some(0)); + self.filter_list_state.select(Some(0)); } } } diff --git a/src/clef.rs b/src/clef.rs index f97b56d..af733f9 100644 --- a/src/clef.rs +++ b/src/clef.rs @@ -43,21 +43,29 @@ pub struct ClefLine<'a> { } impl<'a> ClefLine<'a> { - pub fn new(line: &str) -> Result { let mut clef: ClefLine = serde_json::from_str(line).unwrap(); clef.data = line.to_string(); clef.template = clef.render()?; - let time = DateTime::parse_from_rfc3339(clef.time.as_str()); + let time = DateTime::parse_from_rfc3339(clef.time.as_str()); clef.time = time.unwrap().format("%d.%m.%y %H:%M:%S").to_string(); clef.row = Row::new(vec![ - Cell::from(["[".to_string(),clef.time.to_string(),"|".to_string(), clef.level.to_string(), "]".to_string()].join("")), + Cell::from( + [ + "[".to_string(), + clef.time.to_string(), + "|".to_string(), + clef.level.to_string(), + "]".to_string(), + ] + .join(""), + ), Cell::from(clef.template.to_string()), ]); Ok(clef) } - pub fn render(&mut self) -> Result { + pub fn render(&mut self) -> Result { let start_bracket = "{"; let end_bracket = "}"; let mut base = self.template.clone(); diff --git a/src/event_log_level.rs b/src/event_log_level.rs new file mode 100644 index 0000000..52dd0b0 --- /dev/null +++ b/src/event_log_level.rs @@ -0,0 +1,16 @@ + +#[derive(Debug,PartialEq)] +pub struct EventLogLevel { + pub selected: bool, + pub value: String, +} + +impl ToString for EventLogLevel { + fn to_string(&self) -> String { + if !self.selected { + self.value.to_string() + }else { + format!("* {}",self.value) + } + } +} diff --git a/src/main.rs b/src/main.rs index 11bc4cf..8c87e43 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,20 +16,26 @@ pub mod update; // clef parser pub mod clef; -use std::{ fs, io::{self}}; +pub mod event_log_level; + +use std::{ + fs, + io::{self}, +}; -use app::App; -use clap::Parser; -use event::{Event, EventHandler}; -use ratatui::{backend::CrosstermBackend, widgets::TableState, Terminal}; -use tui::Tui; -use update::update; #[derive(Parser, Debug)] #[command(author, version, about)] struct Args { file: Option, } +use app::App; +use clap::Parser; +use event::{Event, EventHandler}; +use ratatui::{backend::CrosstermBackend, widgets::{ListState, TableState}, Terminal}; +use tui::Tui; +use update::update; + fn main() -> Result<(), Box> { let args = Args::parse(); let path: String; @@ -70,8 +76,10 @@ fn create_app(path: String) -> Result, io::Error> { let lines = read_file(path.as_str())?; let mut app = App::new(); app.file_path = path; - app.table_state = TableState::new(); - app.table_state.select(Some(0)); + app.event_table_state = TableState::new(); + app.filter_list_state = ListState::default(); + app.filter_list_state.select(Some(0)); + app.event_table_state.select(Some(0)); app.load_lines(&lines); Ok(app) } diff --git a/src/ui.rs b/src/ui.rs index fa98360..5cdee30 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,7 +1,10 @@ use ratatui::{ - layout::{Constraint, Direction, Layout}, - style::{Modifier, Style, Stylize}, - widgets::{block, Block, Borders, Paragraph, Row, Table, Wrap}, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style, Stylize}, + widgets::{ + block::{self, Title}, + Block, Borders, Clear, List, ListDirection, Paragraph, Row, Table, Wrap, + }, Frame, }; @@ -13,108 +16,179 @@ struct Detail { event_id: String, } -use crate::{app::App, clef::ClefLine}; +use crate::{ + app::{App, AppState}, + clef::ClefLine, +}; pub fn render(app: &mut App, f: &mut Frame) { - let widths = [Constraint::Length(30), Constraint::Percentage(100)]; - let mut clef_rows: Vec = vec![]; - let main = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(70), Constraint::Percentage(30)]) - .split(f.size()); - - let detail_area = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(20), Constraint::Percentage(79)]) - .split(main[1]); - - let detail_header = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(100)]) - .split(detail_area[0]); - - let detail_footer = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(100)]) - .split(detail_area[1]); - - for line in &app.lines { - clef_rows.push(line.row.clone()); - } + match app.app_state { + AppState::ITERATING => { + let widths = [Constraint::Length(30), Constraint::Percentage(100)]; + let mut clef_rows: Vec<(&ClefLine, Row)> = vec![]; + let main = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(70), Constraint::Percentage(30)]) + .split(f.size()); - let selected_row_index = app.table_state.selected().unwrap(); - let selected_row: &ClefLine = app.lines.get(selected_row_index).unwrap(); - let selection_text = format!("{}|{}", selected_row_index, clef_rows.len() - 1); - let detail: Detail = Detail { - timestap: selected_row.time.to_string(), - message: selected_row.template.to_string(), - level: selected_row.level.to_string(), - exception: selected_row.exception.to_string(), - event_id: selected_row.eventid.to_string(), - }; - let table = Table::new(clef_rows, widths) - .column_spacing(0) - .header(Row::new(vec!["Time|Level", "Message"]).style(Style::new().bold())) - .block( - Block::default() - .title("Clever") - .title( - block::Title::from(app.file_path.as_str()) - .position(block::Position::Top) - .alignment(ratatui::layout::Alignment::Left), - ) - .title( - block::Title::from(selection_text) - .position(block::Position::Bottom) - .alignment(ratatui::layout::Alignment::Center), + let detail_area = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(20), Constraint::Percentage(79)]) + .split(main[1]); + + let detail_header = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(100)]) + .split(detail_area[0]); + + let detail_footer = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(100)]) + .split(detail_area[1]); + + for line in &app.lines { + let event_level = line.level.to_string(); + if app + .event_types + .iter() + .any(|level| level.value == event_level && level.selected) + { + clef_rows.push((&line, line.row.clone())); + } + } + + let mut selected_row_index = app.event_table_state.selected().unwrap(); + let selected_row: &ClefLine = match clef_rows.get(selected_row_index) { + None => { + app.event_table_state.select(Some(0)); + selected_row_index = 0; + clef_rows.get(0).unwrap().0 + } + Some(val) => val.0, + }; + let selection_text = format!("{}|{}", selected_row_index, clef_rows.len() - 1); + + let detail: Detail = Detail { + timestap: selected_row.time.to_string(), + message: selected_row.template.to_string(), + level: selected_row.level.to_string(), + exception: selected_row.exception.to_string(), + event_id: selected_row.eventid.to_string(), + }; + + let table = Table::new(clef_rows.iter().map(|v| v.1.clone()), widths) + .column_spacing(0) + .header(Row::new(vec!["Time|Level", "Message"]).style(Style::new().bold())) + .block( + Block::default() + .title("Clever") + .title( + block::Title::from(app.file_path.as_str()) + .position(block::Position::Top) + .alignment(ratatui::layout::Alignment::Left), + ) + .title( + block::Title::from(selection_text) + .position(block::Position::Bottom) + .alignment(ratatui::layout::Alignment::Center), + ) + .title_position(ratatui::widgets::block::Position::Top) + .title_alignment(ratatui::layout::Alignment::Center) + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .title_style(Style::default().fg(ratatui::style::Color::Yellow)), ) - .title_position(ratatui::widgets::block::Position::Top) - .title_alignment(ratatui::layout::Alignment::Center) + .style(Style::default().fg(ratatui::style::Color::Yellow)) + .highlight_style(Style::default().reversed()); + f.render_stateful_widget(table, main[0], &mut app.event_table_state); + + let stats = Block::default() .borders(Borders::ALL) + .title(block::Title::from("Detail").position(block::Position::Top)) .border_type(ratatui::widgets::BorderType::Rounded) - .title_style(Style::default().fg(ratatui::style::Color::Yellow)), - ) - .style(Style::default().fg(ratatui::style::Color::Yellow)) - .highlight_style(Style::default().reversed()); - - f.render_stateful_widget(table, main[0], &mut app.table_state); - - let stats = Block::default() - .borders(Borders::ALL) - .title(block::Title::from("Detail").position(block::Position::Top)) - .border_type(ratatui::widgets::BorderType::Rounded) - .title("Quit:'Q'") - .title_position(ratatui::widgets::block::Position::Bottom) - .title_style(Style::default().add_modifier(Modifier::BOLD)) - .title_alignment(ratatui::layout::Alignment::Left) - .title_style(Style::default().fg(ratatui::style::Color::Yellow)) - .border_style(Style::default().fg(ratatui::style::Color::Yellow)) - .style(Style::default()); - - f.render_widget(stats, main[1]); - - let status_details = Paragraph::new(format!( - "{} | {} {} {} ", - detail.timestap, detail.level, detail.exception, detail.event_id - )) - .style(Style::default().fg(ratatui::style::Color::Yellow)) - .block(Block::new().padding(block::Padding { - left: 1, - right: 1, - top: 1, - bottom: 0, - })); - - f.render_widget(status_details, detail_header[0]); - - let rendered_message = Paragraph::new(detail.message) - .style(Style::default().fg(ratatui::style::Color::Yellow)) - .wrap(Wrap { trim: false }) - .block(Block::new().padding(block::Padding { - left: 1, - right: 1, - top: 0, - bottom: 1, - })); - f.render_widget(rendered_message, detail_footer[0]); + .title("Quit:'Q' Filter:'F") + .title_position(ratatui::widgets::block::Position::Bottom) + .title_style(Style::default().add_modifier(Modifier::BOLD)) + .title_alignment(ratatui::layout::Alignment::Left) + .title_style(Style::default().fg(ratatui::style::Color::Yellow)) + .border_style(Style::default().fg(ratatui::style::Color::Yellow)) + .style(Style::default()); + + f.render_widget(stats, main[1]); + + let status_details = Paragraph::new(format!( + "{} | {} {} {} ", + detail.timestap, detail.level, detail.exception, detail.event_id + )) + .style(Style::default().fg(ratatui::style::Color::Yellow)) + .block(Block::new().padding(block::Padding { + left: 1, + right: 1, + top: 1, + bottom: 0, + })); + + f.render_widget(status_details, detail_header[0]); + + let rendered_message = Paragraph::new(detail.message) + .style(Style::default().fg(ratatui::style::Color::Yellow)) + .wrap(Wrap { trim: false }) + .block(Block::new().padding(block::Padding { + left: 1, + right: 1, + top: 0, + bottom: 1, + })); + f.render_widget(rendered_message, detail_footer[0]); + } + AppState::FILTERING => { + f.render_widget(Clear, f.size()); + let area = centered_rect(40, 30, f.size()); + let type_list: Vec = app.event_types.iter().map(|t| t.to_string()).collect(); + let list = List::new(type_list) + .block( + Block::default() + .title("Event Levels") + .borders(Borders::ALL) + .style(Style::default().fg(Color::Yellow)) + .border_type(block::BorderType::Rounded) + .title( + Title::from("Select: Spc | Close: F") + .alignment(ratatui::layout::Alignment::Center) + .position(block::Position::Bottom), + ), + ) + .style(Style::default().fg(Color::White)) + .highlight_style(Style::default().add_modifier(Modifier::BOLD)) + .highlight_symbol(">") + .style(Style::default().fg(Color::Yellow)) + .repeat_highlight_symbol(true) + .direction(ListDirection::TopToBottom); + f.render_stateful_widget(list, area, &mut app.filter_list_state); + } + } + + // ANCHOR: centered_rect + // helper function to create a centered rect using up certain percentage of the available rect `r` + fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + // Cut the given rectangle into three vertical pieces + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + // Then cut the middle vertical piece into three width-wise pieces + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] // Return the middle chunk + } } diff --git a/src/update.rs b/src/update.rs index 8eb830a..7b07d38 100644 --- a/src/update.rs +++ b/src/update.rs @@ -1,19 +1,48 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use crate::app::App; +use crate::app::{App, AppState}; pub fn update(app: &mut App, key_event: KeyEvent) { - match key_event.code { - KeyCode::Esc | KeyCode::Char('q') => app.quit(), - KeyCode::Char('c') | KeyCode::Char('C') => { - if key_event.modifiers == KeyModifiers::CONTROL { - app.quit() - } + match app.app_state { + AppState::ITERATING => { + match key_event.code { + KeyCode::Esc | KeyCode::Char('q') => app.quit(), + KeyCode::Char('c') | KeyCode::Char('C') => { + if key_event.modifiers == KeyModifiers::CONTROL { + app.quit() + } + } + KeyCode::Right | KeyCode::Char('j') => app.move_row_down(10), + KeyCode::Left | KeyCode::Char('k') => app.move_row_up(10), + KeyCode::Up | KeyCode::Char('h') => app.move_row_up(1), + KeyCode::Down | KeyCode::Char('l') => app.move_row_down(1), + KeyCode::Char('f') | KeyCode::Char('F') => match app.app_state { + AppState::ITERATING => app.app_state = AppState::FILTERING, + AppState::FILTERING => app.app_state = AppState::ITERATING, + }, + _ => {} + }; } - KeyCode::Right | KeyCode::Char('j') => app.move_row_down(10), - KeyCode::Left | KeyCode::Char('k') => app.move_row_up(10), - KeyCode::Up | KeyCode::Char('h') => app.move_row_up(1), - KeyCode::Down | KeyCode::Char('l') => app.move_row_down(1), - _ => {} - }; -} \ No newline at end of file + AppState::FILTERING => match key_event.code { + KeyCode::Esc | KeyCode::Char('q') => app.quit(), + KeyCode::Char('c') | KeyCode::Char('C') => { + if key_event.modifiers == KeyModifiers::CONTROL { + app.quit() + } + } + KeyCode::Right | KeyCode::Char('j') => app.move_list_up(), + KeyCode::Left | KeyCode::Char('k') => app.move_list_down(), + KeyCode::Up | KeyCode::Char('h') => app.move_list_up(), + KeyCode::Down | KeyCode::Char('l') => app.move_list_down(), + KeyCode::Enter | KeyCode::Char(' ') => { + let selected = app.filter_list_state.selected().unwrap(); + app.event_types[selected].selected = !app.event_types[selected].selected; + } + KeyCode::Char('f') | KeyCode::Char('F') => match app.app_state { + AppState::ITERATING => app.app_state = AppState::FILTERING, + AppState::FILTERING => app.app_state = AppState::ITERATING, + }, + _ => {} + }, + } +}