From 6d3b67e823c5f5b0b37cd68397a91a6402ff74e6 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Fri, 27 Feb 2026 00:28:08 +0100 Subject: [PATCH] allow filtering by log level --- data/src/types.rs | 2 +- src/app.rs | 29 +++++++++++++++----- src/grouping/mod.rs | 3 +++ src/grouping/unique.rs | 3 +++ src/ui/footer.rs | 8 +++--- src/ui/input.rs | 8 ++++-- src/ui/list.rs | 46 ++++++++++++++++++++++++++++++++ src/ui/mod.rs | 30 +++++++++++++++++++-- src/ui/state.rs | 60 +++++++++++++++++++++++++++++++++++------- 9 files changed, 164 insertions(+), 25 deletions(-) diff --git a/data/src/types.rs b/data/src/types.rs index 93f9f28..6bc49fd 100644 --- a/data/src/types.rs +++ b/data/src/types.rs @@ -10,7 +10,7 @@ pub enum LogLevel { Info = 1, Warn = 2, Error = 3, - Exception, + Exception = 4, #[default] Unknown, } diff --git a/src/app.rs b/src/app.rs index 934a910..69e7d9b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,6 +2,7 @@ use crate::grouping::{GroupingResult, UniqueGrouping}; use crate::logfile::{LogFile, LogLine, LogLineNumber}; use crate::logs::ParsedLogs; use crate::timegraph::{SparkLine, TimeGraph}; +use logsmash_data::LogLevel; use regex::{escape, Regex, RegexBuilder}; use serde_json::Error as JsonError; use std::cell::OnceCell; @@ -83,10 +84,10 @@ impl<'logs> LineSet<'logs> { } pub fn matches(&self, filter: &Filter) -> bool { + let line = self.lines[0]; if filter.is_empty() { return true; } - let line = self.lines[0]; if line.request_id == filter.filter { return true; } @@ -98,15 +99,23 @@ impl<'logs> LineSet<'logs> { } } -#[derive(Default, Clone)] +#[derive(Clone)] pub struct Filter { filter: String, regexes: Vec, + pub levels: [bool; 6], +} + +impl Default for Filter { + fn default() -> Self { + EMPTY_FILTER.clone() + } } pub static EMPTY_FILTER: Filter = Filter { filter: String::new(), regexes: Vec::new(), + levels: [true; 6], }; impl Filter { @@ -122,18 +131,24 @@ impl Filter { .collect() } - #[allow(dead_code)] - pub fn new(filter: String) -> Self { - let regexes = Self::build_regex(&filter); - Filter { filter, regexes } + pub fn level(&self, level: LogLevel) -> bool { + self.levels[level as i64 as usize] + } + + pub fn toggle_level(&mut self, level: LogLevel) { + self.levels[level as i64 as usize] = !self.levels[level as i64 as usize]; } pub fn parts(&self) -> impl Iterator { self.regexes.iter() } + pub fn all_levels(&self) -> bool { + self.levels.iter().all(|level| *level) + } + pub fn is_empty(&self) -> bool { - self.filter.is_empty() + self.filter.is_empty() && self.levels.iter().all(|level| *level) } pub fn push(&mut self, c: char) { diff --git a/src/grouping/mod.rs b/src/grouping/mod.rs index 38e4e00..d861bd2 100644 --- a/src/grouping/mod.rs +++ b/src/grouping/mod.rs @@ -95,6 +95,9 @@ impl<'logs> LogGrouping<'logs> { if filter.is_empty() { return true; } + if !filter.all_levels() && !self.lines.lines.iter().any(|line| filter.level(line.level)) { + return false; + } match &self.result { Some(result) => result.matches(filter), _ => true, diff --git a/src/grouping/unique.rs b/src/grouping/unique.rs index 8f58b74..f9e1532 100644 --- a/src/grouping/unique.rs +++ b/src/grouping/unique.rs @@ -28,6 +28,9 @@ impl<'a> GroupingResult<'a> for UniqueGrouping<'a> { if filter.is_empty() { return true; } + if !filter.level(self.line.level) { + return false; + } filter.parts().all(|filter_part| { if let Some(ex) = self.line.exception.as_ref() { if filter_part.is_match(&ex.message) || filter_part.is_match(&ex.exception) { diff --git a/src/ui/footer.rs b/src/ui/footer.rs index 058fc0c..951e9ef 100644 --- a/src/ui/footer.rs +++ b/src/ui/footer.rs @@ -55,13 +55,13 @@ pub fn footer<'a>(app: &App, params: FooterParams<'a>) -> Table<'a> { fn help(page: UiPage) -> &'static str { match page { - UiPage::GroupList => "«Q» Exit - «Enter» Select - «F» Filter - «E» Show parse errors", - UiPage::Group => "«Q» Exit - «Enter» Select - «F» Filter - «G» Group By - «Esc» Back", + UiPage::GroupList => "«Q» Exit - «Enter» Select - «F» Filter - «L» Toggle Level - «E» Show parse errors", + UiPage::Group => "«Q» Exit - «Enter» Select - «F» Filter - «L» Toggle Level - «G» Group By - «Esc» Back", UiPage::DistinctLogs => { - "«Q» Exit - «F» Filter - «Esc» Back - «C» Copy log line - «Shift» + «C» Copy all lines - «G» Group By - «R» Show logs for request" + "«Q» Exit - «F» Filter - «L» Toggle Level - «Esc» Back - «C» Copy log line - «Shift» + «C» Copy all lines - «G» Group By - «R» Show logs for request" } UiPage::ByRequest => { - "«Q» Exit - «F» Filter - «Esc» Back - «C» Copy log line - «Shift» + «C» Copy all lines" + "«Q» Exit - «F» Filter - «L» Toggle Level - «Esc» Back - «C» Copy log line - «Shift» + «C» Copy all lines" } UiPage::Log => { "«Q» Exit - «Esc» Back - «R» Toggle raw - «C» Copy log line - «R» Show logs for request" diff --git a/src/ui/input.rs b/src/ui/input.rs index a186134..9d207a2 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -19,11 +19,12 @@ pub enum UiEvent { Copy, CopyAll, EnterFilterMode, - ClearFilter, Text(char), PopText(PopMode), ByRequest, GroupBy, + ToggleLevelFilterMode, + Toggle, } pub enum PopMode { @@ -61,7 +62,7 @@ pub fn handle_events(page: UiPage, ui_state: &UiState) -> io::Result Some(UiEvent::ClearFilter), + (Mode::FilterInput, KeyCode::Esc) => Some(UiEvent::Back), (Mode::FilterInput, KeyCode::F(4)) => Some(UiEvent::Back), (Mode::FilterInput, KeyCode::Backspace) => { Some(UiEvent::PopText(PopMode::Character)) @@ -72,6 +73,9 @@ pub fn handle_events(page: UiPage, ui_state: &UiState) -> io::Result Some(UiEvent::Text(c)), + + (_, KeyCode::Char(' ')) => Some(UiEvent::Toggle), + (_, KeyCode::Char('l')) => Some(UiEvent::ToggleLevelFilterMode), _ => None, }); } diff --git a/src/ui/list.rs b/src/ui/list.rs index d257520..e06ce0e 100644 --- a/src/ui/list.rs +++ b/src/ui/list.rs @@ -3,6 +3,7 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::StatefulWidget; use ratatui::widgets::{Block, HighlightSpacing, List, ListItem, ListState}; +use std::fmt::Display; pub struct SelectList<'a> { list: List<'a>, @@ -37,6 +38,51 @@ impl StatefulWidget for SelectList<'_> { } } +pub struct ToggleList<'a> { + list: List<'a>, +} + +impl<'a> ToggleList<'a> { + pub fn new(items: T) -> Self + where + T: IntoIterator, + S: Display, + { + fn symbol(state: bool) -> &'static str { + if state { + "☑" + } else { + "☐" + } + } + + ToggleList { + list: List::new( + items + .into_iter() + .map(|(text, state)| format!("{} {text}", symbol(state))), + ) + .highlight_style(TABLE_SELECTED_STYLE) + .highlight_spacing(HighlightSpacing::Always), + } + } + + #[must_use = "method moves the value of self and returns the modified value"] + pub fn block(self, block: Block<'a>) -> Self { + ToggleList { + list: self.list.block(block), + } + } +} + +impl StatefulWidget for ToggleList<'_> { + type State = SizedListState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + StatefulWidget::render(self.list, area, buf, &mut state.list_state); + } +} + #[derive(PartialEq)] pub struct SizedListState { list_state: ListState, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index a8af5e0..0b30944 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -7,12 +7,12 @@ use crate::ui::footer::footer; use crate::ui::grouping_list::grouping_list; use crate::ui::histogram::UiHistogram; use crate::ui::input::handle_events; -use crate::ui::list::SelectList; +use crate::ui::list::{SelectList, ToggleList}; use crate::ui::single_group::single_group; use crate::ui::single_log::single_log; use crate::ui::state::{ DistinctLogsState, ErrorLinesState, ErrorState, GroupByMenuState, GroupListState, GroupState, - LogState, UiState, + LevelsMenuState, LogState, UiState, }; use ratatui::crossterm::event::{DisableMouseCapture, EnableMouseCapture}; use ratatui::crossterm::terminal::{ @@ -189,6 +189,32 @@ fn ui(frame: &mut Frame, app: &App, state: &mut UiState) { frame.render_widget(Clear, area); frame.render_stateful_widget(popup, area, list_state); } + UiState::LevelsMenu(LevelsMenuState { + previous, + list_state, + levels, + .. + }) => { + ui(frame, app, previous); + let options = [ + ("Debug", levels[0]), + ("Info", levels[1]), + ("Warn", levels[2]), + ("Error", levels[3]), + ("Exception", levels[4]), + ("Unknown", levels[5]), + ]; + let area = center( + frame.area(), + Constraint::Percentage(20), + Constraint::Length(options.len() as u16 + 2), // top and bottom border + content + ); + let block = Block::bordered().title("Levels"); + + let popup = ToggleList::new(options).block(block); + frame.render_widget(Clear, area); + frame.render_stateful_widget(popup, area, list_state); + } UiState::Distinct(DistinctLogsState { lines, table_state, diff --git a/src/ui/state.rs b/src/ui/state.rs index ad47666..9a212f9 100644 --- a/src/ui/state.rs +++ b/src/ui/state.rs @@ -19,6 +19,7 @@ pub enum UiState<'a> { GroupList(GroupListState<'a>), Group(GroupState<'a>), GroupByMenu(GroupByMenuState<'a>), + LevelsMenu(LevelsMenuState<'a>), Distinct(DistinctLogsState<'a>), Log(LogState<'a>), Errors(ErrorLinesState<'a>), @@ -36,6 +37,7 @@ impl<'a> UiState<'a> { .. }) | UiState::GroupByMenu(_) + | UiState::LevelsMenu(_) | UiState::Distinct(DistinctLogsState { .. }) | UiState::Log(LogState { .. }) | UiState::Errors(ErrorLinesState { .. }) @@ -213,6 +215,24 @@ impl<'a> GroupState<'a> { } } +pub struct LevelsMenuState<'a> { + pub previous: Box>, + pub list_state: SizedListState, + pub levels: [bool; 6], +} + +impl PartialEq for LevelsMenuState<'_> { + fn eq(&self, other: &Self) -> bool { + self.list_state == other.list_state + } +} + +impl<'a> LevelsMenuState<'a> { + fn selected(&self) -> usize { + self.list_state.selected() + } +} + impl PartialEq for GroupState<'_> { fn eq(&self, other: &Self) -> bool { self.result.result == other.result.result @@ -379,6 +399,7 @@ impl<'a> UiState<'a> { UiState::Quit | UiState::GroupList(_) => UiPage::GroupList, UiState::Group(_) => UiPage::Group, UiState::GroupByMenu(_) => UiPage::Group, // todo + UiState::LevelsMenu(_) => UiPage::Group, // todo UiState::Distinct(DistinctLogsState { grouping: DistinctLogGrouping::Message, .. @@ -455,6 +476,7 @@ impl<'a> UiState<'a> { fn list_state_mut(&mut self) -> Option<&mut SizedListState> { match self { UiState::GroupByMenu(state) => Some(&mut state.list_state), + UiState::LevelsMenu(state) => Some(&mut state.list_state), _ => None, } } @@ -511,6 +533,7 @@ impl<'a> UiState<'a> { UiState::Group(_) => UI_HEADER_SIZE + 1, UiState::Distinct(_) => UI_HEADER_SIZE + 1, UiState::GroupByMenu(_) => 0, + UiState::LevelsMenu(_) => 0, UiState::Log(_) => 0, UiState::Errors(_) => 0, UiState::Error(_) => 0, @@ -602,9 +625,6 @@ impl<'a> UiState<'a> { state.filter.pop(); (true, state.into()) } - (UiState::GroupByMenu(GroupByMenuState { previous, .. }), UiEvent::ClearFilter) => { - (true, *previous) - } (UiState::Distinct(state), UiEvent::Select) => { let selected = state.selected(); (true, state.enter(selected, app)) @@ -657,7 +677,7 @@ impl<'a> UiState<'a> { copy_osc(raw); (false, UiState::Errors(state)) } - (mut ui, UiEvent::EnterFilterMode) if ui.mode() != Mode::FilterInput => { + (mut ui, UiEvent::EnterFilterMode) if ui.mode() == Mode::Normal => { ui.set_mode(Mode::FilterInput); (true, ui) } @@ -676,12 +696,34 @@ impl<'a> UiState<'a> { } (true, ui) } - (mut ui, UiEvent::ClearFilter) if ui.mode() != Mode::Normal => { - if let Some(filter) = ui.filter_mut() { - filter.clear(); + ( + ui @ (UiState::GroupList(_) | UiState::Group(_) | UiState::Distinct(_)), + UiEvent::ToggleLevelFilterMode, + ) if ui.mode() == Mode::Normal => { + let levels = ui.filter().map(|filter| filter.levels).unwrap_or([true; 6]); + let state = LevelsMenuState { + previous: Box::new(ui), + list_state: SizedListState::new(levels.len()), + levels, + }; + (true, UiState::LevelsMenu(state)) + } + (UiState::LevelsMenu(mut state), UiEvent::Toggle) => { + state.levels[state.selected()] = !state.levels[state.selected()]; + (true, UiState::LevelsMenu(state)) + } + ( + UiState::LevelsMenu(LevelsMenuState { + mut previous, + levels, + .. + }), + UiEvent::Back | UiEvent::ToggleLevelFilterMode | UiEvent::Select, + ) => { + if let Some(filter) = previous.filter_mut() { + filter.levels = levels; } - ui.set_mode(Mode::Normal); - (true, ui) + (true, *previous) } ( mut ui @ UiState::GroupList(GroupListState {