event more grouping prep

This commit is contained in:
Robin Appelman 2025-08-16 13:04:45 +02:00
commit 9e66967141
11 changed files with 149 additions and 145 deletions

View file

@ -1,7 +1,5 @@
use crate::grouping::{group_lines_by, FilterGrouping, LogGrouping};
use crate::logfile::{LogFile, LogLine, LogLineNumber};
use crate::logs::ParsedLogs;
use crate::matcher::MatchResult;
use crate::timegraph::{SparkLine, TimeGraph};
use regex::{escape, Regex, RegexBuilder};
use serde_json::Error as JsonError;
@ -12,16 +10,11 @@ use time::OffsetDateTime;
pub struct App<'logs> {
pub lines: &'logs ParsedLogs<'logs>,
pub matches: &'logs [LogGrouping<'logs, MatchResult>],
pub log_file: &'logs LogFile,
pub unmatched_count: usize,
}
impl<'logs> App<'logs> {
pub fn match_lines(&self) -> usize {
self.matches.len() + 1
}
pub fn get_source_line(&self, index: LogLineNumber) -> Option<&'logs str> {
self.log_file.nth(index)
}
@ -49,6 +42,7 @@ impl<'logs> App<'logs> {
}
}
#[derive(Clone)]
pub struct LineSet<'logs> {
pub lines: Vec<&'logs LogLine<'logs>>,
pub histogram: OnceCell<TimeGraph>,
@ -99,11 +93,6 @@ impl<'logs> LineSet<'logs> {
pub fn iter<'a>(&'a self) -> impl Iterator<Item = &'logs LogLine<'logs>> + use<'a, 'logs> {
self.lines.iter().copied()
}
pub fn group_by<G: FilterGrouping>(&self) -> Vec<LogGrouping<G::Result>> {
let _grouped = group_lines_by(self.lines.iter().copied(), G::filter);
todo!()
}
}
#[derive(Default, Clone)]

View file

@ -2,6 +2,7 @@ mod unique;
use crate::app::{Filter, LineSet};
use crate::logfile::LogLine;
use crate::matcher::MatchResult;
use crate::timegraph::{SparkLine, TimeGraph};
use ratatui::layout::{Alignment, Constraint};
use std::borrow::Cow;
@ -11,49 +12,39 @@ use std::ops::RangeInclusive;
use time::OffsetDateTime;
pub use unique::*;
pub trait Grouping {
const HEADER: &'static [(&'static str, Alignment)];
const WIDTHS: &'static [Constraint];
}
pub trait FilterGrouping: Grouping {
type Result: Grouping;
type Identifier: Ord;
fn filter(line: &LogLine) -> Self::Identifier;
pub struct GroupingUi {
pub header: &'static [(&'static str, Alignment)],
pub widths: &'static [Constraint],
}
#[allow(clippy::len_without_is_empty)]
pub trait GroupingResult {
type Grouping: Grouping;
type Next: FilterGrouping;
pub trait GroupingResult<'a> {
fn height(&self) -> usize {
1
}
fn matches(&self, filter: &Filter) -> bool;
fn render(&self) -> impl Iterator<Item = Cow<str>>;
fn render(&self) -> impl Iterator<Item = Cow<'a, str>>;
}
pub struct LogGrouping<'logs, G> {
#[derive(Clone)]
pub struct LogGrouping<'logs> {
pub name: Option<&'static str>,
pub result: Option<G>,
pub result: Option<Groupings>,
pub count: usize,
pub lines: LineSet<'logs>,
pub by_identifier: OnceCell<Vec<LineSet<'logs>>>,
}
impl<'logs, G: GroupingResult> LogGrouping<'logs, G> {
pub fn new(result: G, lines: Vec<&'logs LogLine<'logs>>) -> Self {
impl<'logs> LogGrouping<'logs> {
pub fn new(result: impl Into<Groupings>, lines: Vec<&'logs LogLine<'logs>>) -> Self {
let count = lines.len();
let lines = LineSet::new(lines);
LogGrouping {
name: None,
result: Some(result),
result: Some(result.into()),
count,
lines,
by_identifier: OnceCell::new(),
@ -122,3 +113,28 @@ where
list.reverse();
list
}
#[derive(PartialEq, Clone)]
pub enum Groupings {
Match(MatchResult),
}
impl From<MatchResult> for Groupings {
fn from(value: MatchResult) -> Self {
Groupings::Match(value)
}
}
impl<'a> GroupingResult<'a> for Groupings {
fn matches(&self, filter: &Filter) -> bool {
match self {
Groupings::Match(r) => r.matches(filter),
}
}
fn render(&self) -> impl Iterator<Item = Cow<'a, str>> {
match self {
Groupings::Match(r) => r.render(),
}
}
}

View file

@ -1,42 +1,29 @@
use crate::app::Filter;
use crate::grouping::{FilterGrouping, Grouping, GroupingResult};
use crate::grouping::{GroupingResult, GroupingUi};
use crate::logfile::LogLine;
use ratatui::layout::{Alignment, Constraint};
use std::borrow::Cow;
pub struct UniqueLog;
impl Grouping for UniqueLog {
const HEADER: &'static [(&'static str, Alignment)] = &[
("Level", Alignment::Left),
("App", Alignment::Left),
("Message", Alignment::Left),
];
const WIDTHS: &'static [Constraint] = &[
Constraint::Min(10),
Constraint::Min(20),
Constraint::Percentage(100),
];
}
impl FilterGrouping for UniqueLog {
type Result = UniqueLog;
type Identifier = u64;
fn filter(line: &LogLine) -> Self::Identifier {
line.identity()
}
}
pub struct UniqueGrouping<'a> {
pub line: &'a LogLine<'a>,
}
impl<'a> GroupingResult for UniqueGrouping<'a> {
type Grouping = UniqueLog;
type Next = UniqueLog;
pub const UNIQUE_GROUPING_UI: GroupingUi = GroupingUi {
header: &[
("Level", Alignment::Left),
("App", Alignment::Left),
("Message", Alignment::Left),
],
widths: &[
Constraint::Min(10),
Constraint::Min(20),
Constraint::Percentage(100),
],
};
impl<'a> GroupingResult<'a> for UniqueGrouping<'a> {
fn matches(&self, filter: &Filter) -> bool {
if filter.is_empty() {
return true;
@ -46,7 +33,7 @@ impl<'a> GroupingResult for UniqueGrouping<'a> {
.all(|filter_part| filter_part.is_match(&self.line.message))
}
fn render(&self) -> impl Iterator<Item = Cow<str>> {
fn render(&self) -> impl Iterator<Item = Cow<'a, str>> {
[
Cow::from(self.line.level.as_str()),
Cow::from(self.line.app.as_ref()),

View file

@ -152,8 +152,8 @@ fn main() -> MainResult {
matched_lines.sort_by_key(|(_, lines)| lines.len());
matched_lines.reverse();
let all = LogGrouping::<MatchResult>::named("All", parsed_log.find_lines(|_| true).collect());
let unmatched = LogGrouping::<MatchResult>::named("Unmatched", unmatched_lines);
let all = LogGrouping::named("All", parsed_log.find_lines(|_| true).collect());
let unmatched = LogGrouping::named("Unmatched", unmatched_lines);
let mut matches: Vec<_> = matched_lines
.into_par_iter()
@ -169,7 +169,6 @@ fn main() -> MainResult {
let app = App {
lines: &parsed_log,
matches: &matches,
log_file: &log_file,
unmatched_count,
};
@ -180,7 +179,7 @@ fn main() -> MainResult {
drop(progress);
run_ui(app)?;
run_ui(app, matches)?;
Ok(())
}

View file

@ -1,5 +1,5 @@
use crate::app::Filter;
use crate::grouping::{Grouping, GroupingResult, UniqueLog};
use crate::grouping::{GroupingResult, GroupingUi};
use crate::logfile::logline::{Exception, LogLine};
use crate::logfile::LineNumber;
use itertools::Either;
@ -211,24 +211,20 @@ impl From<Vec<LoggingStatementWithPathPrefix>> for MatchResult {
}
}
impl Grouping for LoggingStatementWithPathPrefix {
const HEADER: &'static [(&'static str, Alignment)] = &[
pub const MATCH_GROUPING_UI: GroupingUi = GroupingUi {
header: &[
("Statement", Alignment::Left),
("File", Alignment::Left),
("Line", Alignment::Right),
];
const WIDTHS: &'static [Constraint] = &[
],
widths: &[
Constraint::Percentage(70),
Constraint::Percentage(30),
Constraint::Min(6),
];
}
impl GroupingResult for MatchResult {
type Grouping = LoggingStatementWithPathPrefix;
type Next = UniqueLog;
],
};
impl<'a> GroupingResult<'a> for MatchResult {
fn height(&self) -> usize {
self.count()
}
@ -256,7 +252,7 @@ impl GroupingResult for MatchResult {
})
}
fn render(&self) -> impl Iterator<Item = Cow<str>> {
fn render(&self) -> impl Iterator<Item = Cow<'a, str>> {
let mut message = String::new();
let mut paths = String::new();
let mut lines = String::new();

View file

@ -2,6 +2,7 @@ use hdrhistogram::Histogram;
use ratatui::text::Text;
use std::borrow::Cow;
use std::cmp::max;
use std::fmt::{Display, Formatter};
use time::OffsetDateTime;
#[derive(Clone)]
@ -60,10 +61,18 @@ impl TimeGraph {
}
// the biggest sparkline char is 3 bytes
#[derive(Clone, Copy)]
pub struct SparkLine {
bytes: [u8; 10 * 3],
}
impl SparkLine {
pub fn as_str(&self) -> &str {
// SAFETY: we only put bytes into the buffer from encode_utf8
unsafe { str::from_utf8_unchecked(&self.bytes).trim_end_matches(char::from(0)) }
}
}
impl From<[char; 10]> for SparkLine {
fn from(value: [char; 10]) -> Self {
let mut buff = [0; 10 * 3];
@ -78,17 +87,19 @@ impl From<[char; 10]> for SparkLine {
impl<'a> From<&'a SparkLine> for Text<'a> {
fn from(value: &'a SparkLine) -> Self {
// SAFETY: we only put bytes into the buffer from encode_utf8
let str = unsafe { str::from_utf8_unchecked(&value.bytes).trim_end_matches(char::from(0)) };
Text::raw(str)
Text::raw(value.as_str())
}
}
impl<'a> From<&'a SparkLine> for Cow<'a, str> {
fn from(value: &'a SparkLine) -> Self {
// SAFETY: we only put bytes into the buffer from encode_utf8
let str = unsafe { str::from_utf8_unchecked(&value.bytes).trim_end_matches(char::from(0)) };
str.into()
value.as_str().into()
}
}
impl Display for SparkLine {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}

View file

@ -1,5 +1,5 @@
use crate::app::Filter;
use crate::grouping::{Grouping, GroupingResult, LogGrouping};
use crate::grouping::{GroupingResult, GroupingUi, LogGrouping};
use crate::ui::style::TABLE_HEADER_STYLE;
use crate::ui::table::ScrollbarTable;
use ratatui::prelude::*;
@ -8,12 +8,14 @@ use std::borrow::Cow;
use std::ops::RangeInclusive;
use time::OffsetDateTime;
pub fn grouping_list<'a, G: GroupingResult>(
items: &'a [LogGrouping<'a, G>],
pub fn grouping_list<'a>(
items: &[LogGrouping<'a>],
ui: &GroupingUi,
time_range: RangeInclusive<OffsetDateTime>,
filter: &Filter,
) -> ScrollbarTable<'a> {
let header = G::Grouping::HEADER
let header = ui
.header
.iter()
.copied()
.chain([("Time", Alignment::Left), ("Count", Alignment::Left)]);
@ -25,12 +27,15 @@ pub fn grouping_list<'a, G: GroupingResult>(
.style(TABLE_HEADER_STYLE)
.height(1);
ScrollbarTable::new(
items
let items: Vec<Row<'a>> = items
.iter()
.filter(|result| result.matches(filter))
.map(|result| grouped_row(result, time_range.clone())),
G::Grouping::WIDTHS
.map(|result| grouped_row(result, time_range.clone()))
.collect();
ScrollbarTable::new(
items,
ui.widths
.iter()
.copied()
.chain([Constraint::Length(10), Constraint::Min(10)]),
@ -38,14 +43,14 @@ pub fn grouping_list<'a, G: GroupingResult>(
.header(header)
}
fn grouped_row<'a, G: GroupingResult>(
grouping: &'a LogGrouping<'a, G>,
fn grouped_row<'a>(
grouping: &LogGrouping<'a>,
time_range: RangeInclusive<OffsetDateTime>,
) -> Row<'a> {
if let Some(match_result) = &grouping.result {
let grouping_columns = match_result.render();
let columns = grouping_columns.chain([
Cow::from(grouping.sparkline(time_range)),
Cow::from(grouping.sparkline(time_range).to_string()),
Cow::from(grouping.count().to_string()),
]);
Row::new(columns).height(match_result.height() as u16)
@ -54,7 +59,7 @@ fn grouped_row<'a, G: GroupingResult>(
Text::from(grouping.name.unwrap_or_default()),
Text::from(""),
Text::from(""),
Text::from(grouping.sparkline(time_range)),
Text::from(grouping.sparkline(time_range).to_string()),
Text::from(grouping.count().to_string()),
])
}

View file

@ -1,4 +1,3 @@
use crate::app::App;
use crate::ui::find_hit_row;
use crate::ui::state::{Mode, UiPage, UiState};
use ratatui::crossterm::event;
@ -30,7 +29,7 @@ pub enum PopMode {
Word,
}
pub fn handle_events(page: UiPage, ui_state: &UiState, app: &App) -> io::Result<Option<UiEvent>> {
pub fn handle_events(page: UiPage, ui_state: &UiState) -> io::Result<Option<UiEvent>> {
if event::poll(Duration::from_millis(50))? {
match event::read()? {
Event::Key(key) if key.kind == event::KeyEventKind::Press => {
@ -77,7 +76,7 @@ pub fn handle_events(page: UiPage, ui_state: &UiState, app: &App) -> io::Result<
MouseEventKind::ScrollUp => Some(UiEvent::Scroll(-1)),
MouseEventKind::ScrollDown => Some(UiEvent::Scroll(1)),
MouseEventKind::Down(MouseButton::Left) => {
find_hit_row(mouse.row, ui_state, app).map(UiEvent::Enter)
find_hit_row(mouse.row, ui_state).map(UiEvent::Enter)
}
_ => None,
})

View file

@ -1,6 +1,6 @@
use crate::app::App;
use crate::error::UiError;
use crate::matcher::MatchResult;
use crate::grouping::LogGrouping;
use crate::ui::by_identifier::logs_by_identifier;
use crate::ui::error_list::error_list;
use crate::ui::footer::footer;
@ -38,12 +38,12 @@ mod state;
pub mod style;
mod table;
pub fn run_ui<'a>(app: App<'a>) -> Result<(), UiError> {
pub fn run_ui<'a>(app: App<'a>, matches: Vec<LogGrouping<'a>>) -> Result<(), UiError> {
init_panic_hook();
let mut terminal = init_tui()?;
terminal.clear().ok();
let mut ui_state: UiState = UiState::new(&app);
let mut ui_state: UiState = UiState::new(matches);
let mut update = true;
while !matches!(ui_state, UiState::Quit) {
@ -51,7 +51,7 @@ pub fn run_ui<'a>(app: App<'a>) -> Result<(), UiError> {
terminal.draw(|frame| ui(frame, &app, &mut ui_state))?;
}
update = false;
if let Some(event) = handle_events(ui_state.page(), &ui_state, &app)? {
if let Some(event) = handle_events(ui_state.page(), &ui_state)? {
(update, ui_state) = ui_state.process(event, &app);
}
}
@ -84,9 +84,9 @@ pub fn restore_tui() -> io::Result<()> {
Ok(())
}
fn find_hit_row(row: u16, ui_state: &UiState, app: &App) -> Option<usize> {
fn find_hit_row(row: u16, ui_state: &UiState) -> Option<usize> {
if let Some(table_row) = row.checked_sub(ui_state.content_offset()) {
let selected = ui_state.index_for_row(table_row as usize, app);
let selected = ui_state.index_for_row(table_row as usize);
if selected < ui_state.row_count() {
Some(selected)
} else {
@ -112,16 +112,18 @@ fn ui(frame: &mut Frame, app: &App, state: &mut UiState) {
match state {
UiState::Quit => {}
UiState::GroupList(GroupListState {
items,
table_state,
filter,
ui,
..
}) => {
let selected = table_state.selected();
let histogram = app.matches[selected].histogram(app.time_range());
let histogram = items[selected].histogram(app.time_range());
frame.render_widget(UiHistogram::new(histogram), layout[0]);
frame.render_stateful_widget(
grouping_list::<MatchResult>(app.matches, app.time_range(), filter),
grouping_list(items, ui, app.time_range(), filter),
layout[1],
table_state,
);

View file

@ -1,8 +1,8 @@
use crate::app::{App, Filter, EMPTY_FILTER};
use crate::error::ParseError;
use crate::grouping::LogGrouping;
use crate::grouping::{GroupingUi, LogGrouping};
use crate::logfile::logline::{FullLogLine, LogLine};
use crate::matcher::MatchResult;
use crate::matcher::MATCH_GROUPING_UI;
use crate::ui::footer::FooterParams;
use crate::ui::input::{PopMode, UiEvent};
use crate::ui::table::ScrollbarTableState;
@ -10,13 +10,12 @@ use crate::ui::UI_HEADER_SIZE;
use crate::{copy_osc, parse_line_full};
use derive_more::From;
use ratatui::widgets::TableState;
use std::borrow::Cow;
use std::iter::once;
use std::sync::Arc;
#[derive(Clone, From, PartialEq)]
#[derive(From, PartialEq)]
pub enum UiState<'a> {
GroupList(GroupListState),
GroupList(GroupListState<'a>),
Group(GroupState<'a>),
ByIdentifier(LogsByIdentifierState<'a>),
Log(LogState<'a>),
@ -31,27 +30,30 @@ pub enum Mode {
FilterInput,
}
#[derive(Clone)]
pub struct GroupListState {
pub struct GroupListState<'a> {
pub items: Vec<LogGrouping<'a>>,
pub ui: GroupingUi,
pub table_state: ScrollbarTableState,
pub filter: Filter,
mode: Mode,
}
impl GroupListState {
impl<'a> GroupListState<'a> {
fn selected(&self) -> usize {
self.table_state.selected()
}
fn enter<'a>(self, selected: usize, app: &App<'a>) -> UiState<'a> {
fn enter(self, selected: usize) -> UiState<'a> {
// todo remove clones?
let result = if self.filter.is_empty() {
&app.matches[selected - 1]
self.items[selected].clone()
} else {
app.matches
self.items
.iter()
.filter(|log_match| log_match.matches(&self.filter))
.nth(selected - 1)
.unwrap_or(app.matches.last().unwrap())
.nth(selected)
.unwrap_or(self.items.last().unwrap())
.clone()
};
let table_state = ScrollbarTableState::new(result.by_identifier().len() + 1);
@ -65,15 +67,14 @@ impl GroupListState {
}
}
impl PartialEq for GroupListState {
impl PartialEq for GroupListState<'_> {
fn eq(&self, _other: &Self) -> bool {
true
}
}
#[derive(Clone)]
pub struct GroupState<'a> {
pub result: &'a LogGrouping<'a, MatchResult>,
pub result: LogGrouping<'a>,
pub table_state: ScrollbarTableState,
pub previous: Box<UiState<'a>>,
pub filter: Filter,
@ -89,10 +90,12 @@ impl<'a> GroupState<'a> {
let mut table_state = TableState::default();
table_state.select(Some(0));
// todo: remove clones
let selected_line = if selected == 0 {
&self.result.lines
self.result.lines.clone()
} else if self.filter.is_empty() {
&self.result.by_identifier()[selected - 1]
self.result.by_identifier()[selected - 1].clone()
} else {
self.result
.by_identifier()
@ -100,6 +103,7 @@ impl<'a> GroupState<'a> {
.filter(|grouped| grouped.matches(&self.filter))
.nth(selected - 1)
.expect("filtered select out of bounds")
.clone()
};
let lines = selected_line.lines.as_slice();
let table_state = ScrollbarTableState::new(lines.len());
@ -126,9 +130,8 @@ pub enum GroupedLogGrouping {
Request,
}
#[derive(Clone)]
pub struct LogsByIdentifierState<'a> {
pub lines: Cow<'a, [&'a LogLine<'a>]>,
pub lines: Vec<&'a LogLine<'a>>,
pub table_state: ScrollbarTableState,
pub previous: Box<UiState<'a>>,
pub filter: Filter,
@ -190,7 +193,7 @@ impl<'a> LogsByIdentifierState<'a> {
let table_state = ScrollbarTableState::new(lines.len());
UiState::ByIdentifier(LogsByIdentifierState {
lines: lines.into(),
lines,
mode: Mode::Normal,
filter: Filter::default(),
table_state,
@ -206,7 +209,6 @@ impl PartialEq for LogsByIdentifierState<'_> {
}
}
#[derive(Clone)]
pub struct ErrorLinesState<'a> {
pub table_state: ScrollbarTableState,
pub previous: Box<UiState<'a>>,
@ -218,7 +220,6 @@ impl PartialEq for ErrorLinesState<'_> {
}
}
#[derive(Clone)]
pub struct LogState<'a> {
pub log: &'a LogLine<'a>,
pub full_line: Box<FullLogLine>,
@ -232,7 +233,7 @@ impl<'a> LogState<'a> {
let table_state = ScrollbarTableState::new(lines.len());
UiState::ByIdentifier(LogsByIdentifierState {
lines: lines.into(),
lines,
mode: Mode::Normal,
filter: Filter::default(),
table_state,
@ -249,11 +250,14 @@ impl PartialEq for LogState<'_> {
}
impl<'a> UiState<'a> {
pub fn new(app: &App) -> Self {
pub fn new(matches: Vec<LogGrouping<'a>>) -> Self {
let mut table_state = TableState::default();
table_state.select(Some(0));
let lines = matches.len();
UiState::GroupList(GroupListState {
table_state: ScrollbarTableState::new(app.match_lines()),
items: matches,
ui: MATCH_GROUPING_UI,
table_state: ScrollbarTableState::new(lines),
filter: Filter::default(),
mode: Mode::Normal,
})
@ -344,12 +348,11 @@ impl<'a> UiState<'a> {
}
}
pub fn index_for_row(&self, row: usize, app: &App) -> usize {
pub fn index_for_row(&self, row: usize) -> usize {
match self {
UiState::GroupList(GroupListState { filter, .. }) => {
UiState::GroupList(GroupListState { filter, items, .. }) => {
let mut total_height = 0;
let match_row_counts = app
.matches
let match_row_counts = items
.iter()
.filter(|m| m.matches(filter))
.map(|m| m.row_count());
@ -365,9 +368,9 @@ impl<'a> UiState<'a> {
total_height += row_count;
}
if total_height > row {
app.matches.len() + 1
items.len() + 1
} else {
app.matches.len() + 2
items.len() + 2
}
}
_ => row + self.scroll_offset(),
@ -423,11 +426,9 @@ impl<'a> UiState<'a> {
}
(UiState::GroupList(state), UiEvent::Select) => {
let selected = state.selected();
(true, state.enter(selected, app))
}
(UiState::GroupList(state), UiEvent::Enter(selected)) => {
(true, state.enter(selected, app))
(true, state.enter(selected))
}
(UiState::GroupList(state), UiEvent::Enter(selected)) => (true, state.enter(selected)),
(UiState::GroupList(state), UiEvent::Errors) => {
let table_state = ScrollbarTableState::new(app.error_count());
(
@ -553,7 +554,7 @@ pub enum UiPage {
Error,
}
#[derive(Clone, PartialEq)]
#[derive(PartialEq)]
pub struct ErrorState<'a> {
pub error: Arc<ParseError>,
pub previous: Box<UiState<'a>>,

View file

@ -8,7 +8,7 @@ use ratatui::widgets::{
pub struct ScrollbarTable<'a> {
table: Table<'a>,
scrollbar: Scrollbar<'a>,
scrollbar: Scrollbar<'static>,
}
impl<'a> ScrollbarTable<'a> {
@ -19,7 +19,6 @@ impl<'a> ScrollbarTable<'a> {
C: IntoIterator,
C::Item: Into<Constraint>,
{
let rows: Vec<_> = rows.into_iter().collect();
ScrollbarTable {
table: Table::new(rows, widths)
.block(Block::new().borders(Borders::RIGHT))