allow filtering by log level
All checks were successful
CI / build (push) Successful in 51s
CI / checks (push) Successful in 1m2s
CI / build-nixpkgs (push) Successful in 37s

This commit is contained in:
Robin Appelman 2026-02-27 00:28:08 +01:00
commit 6d3b67e823
9 changed files with 164 additions and 25 deletions

View file

@ -10,7 +10,7 @@ pub enum LogLevel {
Info = 1,
Warn = 2,
Error = 3,
Exception,
Exception = 4,
#[default]
Unknown,
}

View file

@ -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<Regex>,
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<Item = &Regex> {
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) {

View file

@ -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,

View file

@ -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) {

View file

@ -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"

View file

@ -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<Option<UiEv
Some(UiEvent::EnterFilterMode)
}
(Mode::FilterInput, KeyCode::Esc) => 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<Option<UiEv
Some(UiEvent::PopText(PopMode::Word))
}
(Mode::FilterInput, KeyCode::Char(c)) => Some(UiEvent::Text(c)),
(_, KeyCode::Char(' ')) => Some(UiEvent::Toggle),
(_, KeyCode::Char('l')) => Some(UiEvent::ToggleLevelFilterMode),
_ => None,
});
}

View file

@ -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<T, S>(items: T) -> Self
where
T: IntoIterator<Item = (S, bool)>,
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,

View file

@ -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,

View file

@ -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<UiState<'a>>,
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))
}
ui.set_mode(Mode::Normal);
(true, ui)
(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;
}
(true, *previous)
}
(
mut ui @ UiState::GroupList(GroupListState {