histogram

This commit is contained in:
Robin Appelman 2024-07-25 18:35:26 +02:00
commit 13f1f31dd8
12 changed files with 395 additions and 128 deletions

55
Cargo.lock generated
View file

@ -61,6 +61,12 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0"
[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@ -199,6 +205,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"clap", "clap",
"cloud-log-analyser-data", "cloud-log-analyser-data",
"hdrhistogram",
"itertools", "itertools",
"log", "log",
"main_error", "main_error",
@ -271,6 +278,15 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "crossbeam-channel"
version = "0.5.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.20" version = "0.8.20"
@ -414,6 +430,20 @@ dependencies = [
"allocator-api2", "allocator-api2",
] ]
[[package]]
name = "hdrhistogram"
version = "7.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d"
dependencies = [
"base64",
"byteorder",
"crossbeam-channel",
"flate2",
"nom",
"num-traits",
]
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.4.1" version = "0.4.1"
@ -554,6 +584,12 @@ version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.7.4" version = "0.7.4"
@ -575,12 +611,31 @@ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.48.0",
] ]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.19.0" version = "1.19.0"

View file

@ -18,6 +18,7 @@ itertools = "0.13.0"
ratatui = "0.27.0" ratatui = "0.27.0"
tinystr = { version = "0.7.6", features = ["serde"] } tinystr = { version = "0.7.6", features = ["serde"] }
time = { version = "0.3.36", features = ["serde", "serde-well-known"] } time = { version = "0.3.36", features = ["serde", "serde-well-known"] }
hdrhistogram = "7.5.4"
[profile.dev.package."*"] [profile.dev.package."*"]
opt-level = 3 opt-level = 3

View file

@ -1,18 +1,46 @@
use crate::logline::LogLine; use crate::logline::LogLine;
use crate::matcher::MatchResult; use crate::matcher::MatchResult;
use crate::timegraph::TimeGraph;
use cloud_log_analyser_data::StatementList; use cloud_log_analyser_data::StatementList;
pub struct App { pub struct App {
pub lines: Vec<LogLine>, pub lines: Vec<LogLine>,
pub histogram: TimeGraph,
pub log_statements: StatementList, pub log_statements: StatementList,
pub matches: Vec<LogMatch>, pub matches: Vec<LogMatch>,
pub error_count: usize, pub error_count: usize,
pub unmatched: Vec<UnMatched>, pub unmatched: Vec<usize>,
pub unmatched_histogram: TimeGraph,
}
impl App {
pub fn match_lines(&self) -> usize {
let unmatched_line_count = if self.unmatched.is_empty() { 0 } else { 1 };
self.matches.len() + 1 + unmatched_line_count
}
} }
pub struct LogMatch { pub struct LogMatch {
pub result: MatchResult, pub result: MatchResult,
pub lines: Vec<usize>, pub lines: Vec<usize>,
pub histogram: TimeGraph,
}
impl LogMatch {
pub fn new(result: MatchResult, lines: Vec<usize>, all_lines: &[LogLine]) -> Self {
let min_time = all_lines[0].time;
let max_time = all_lines.last().unwrap().time;
let mut histogram = TimeGraph::new(min_time, max_time);
for line in lines.iter().map(|line| &all_lines[*line]) {
histogram.add(line.time);
}
LogMatch {
result,
lines,
histogram,
}
}
} }
impl LogMatch { impl LogMatch {
@ -20,14 +48,3 @@ impl LogMatch {
self.lines.len() self.lines.len()
} }
} }
pub struct UnMatched {
pub app: String,
pub lines: Vec<usize>,
}
impl UnMatched {
pub fn count(&self) -> usize {
self.lines.len()
}
}

View file

@ -1,8 +1,9 @@
use crate::app::{App, LogMatch, UnMatched}; use crate::app::{App, LogMatch};
use crate::error::LogError; use crate::error::LogError;
use crate::logfile::LogFile; use crate::logfile::LogFile;
use crate::logline::LogLine; use crate::logline::LogLine;
use crate::matcher::{MatchResult, Matcher}; use crate::matcher::{MatchResult, Matcher};
use crate::timegraph::TimeGraph;
use crate::ui::run_ui; use crate::ui::run_ui;
use clap::Parser; use clap::Parser;
use cloud_log_analyser_data::{get_statements, MAX_VERSION}; use cloud_log_analyser_data::{get_statements, MAX_VERSION};
@ -15,6 +16,7 @@ mod error;
mod logfile; mod logfile;
mod logline; mod logline;
mod matcher; mod matcher;
mod timegraph;
mod ui; mod ui;
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
@ -49,7 +51,8 @@ fn main() -> MainResult {
let lines = once(first).chain(lines); let lines = once(first).chain(lines);
let mut error_count = 0; let mut error_count = 0;
let mut unmatched_counts: HashMap<String, Vec<usize>> = HashMap::new(); let mut unmatched_counts: HashMap<String, Vec<usize>> = HashMap::new();
let mut parsed_lines = Vec::new(); let mut parsed_lines = Vec::with_capacity(1024);
let mut unmatched_lines = Vec::with_capacity(256);
let mut i = 0; let mut i = 0;
for line in lines { for line in lines {
if line.starts_with('{') { if line.starts_with('{') {
@ -69,7 +72,7 @@ fn main() -> MainResult {
if let Some(entry) = unmatched_counts.get_mut(parsed.app.as_str()) { if let Some(entry) = unmatched_counts.get_mut(parsed.app.as_str()) {
entry.push(i) entry.push(i)
} else { } else {
unmatched_counts.insert(parsed.app.to_string(), vec![i]); unmatched_lines.push(i);
} }
} }
parsed_lines.push(parsed); parsed_lines.push(parsed);
@ -81,21 +84,27 @@ fn main() -> MainResult {
matched_lines.sort_by_key(|(_, lines)| lines.len()); matched_lines.sort_by_key(|(_, lines)| lines.len());
matched_lines.reverse(); matched_lines.reverse();
let mut unmatched_lines: Vec<(_, _)> = unmatched_counts.into_iter().collect(); let histogram = TimeGraph::generate(&parsed_lines);
unmatched_lines.sort_by_key(|(_, lines)| lines.len());
unmatched_lines.reverse(); let matches = matched_lines
.into_iter()
.map(|(result, lines)| LogMatch::new(result, lines, &parsed_lines))
.collect();
let min_time = parsed_lines[0].time;
let max_time = parsed_lines.last().unwrap().time;
let mut unmatched_histogram = TimeGraph::new(min_time, max_time);
for lines in unmatched_lines.iter().map(|line| &parsed_lines[*line]) {
unmatched_histogram.add(lines.time);
}
let app = App { let app = App {
lines: parsed_lines, lines: parsed_lines,
histogram,
log_statements: statements, log_statements: statements,
matches: matched_lines matches,
.into_iter() unmatched: unmatched_lines,
.map(|(result, lines)| LogMatch { result, lines }) unmatched_histogram,
.collect(),
unmatched: unmatched_lines
.into_iter()
.map(|(app, lines)| UnMatched { app, lines })
.collect(),
error_count, error_count,
}; };

View file

@ -92,18 +92,6 @@ pub enum MatchResult {
} }
impl MatchResult { impl MatchResult {
pub fn display<'a>(
&'a self,
log_statements: &'a StatementList,
max_length: usize,
) -> impl Display + 'a {
MatchResultDisplay {
max_length,
log_statements,
result: &self,
}
}
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
match self { match self {
MatchResult::Single(_) => 1, MatchResult::Single(_) => 1,
@ -120,7 +108,6 @@ impl MatchResult {
} }
struct MatchResultDisplay<'a> { struct MatchResultDisplay<'a> {
max_length: usize,
log_statements: &'a StatementList, log_statements: &'a StatementList,
result: &'a MatchResult, result: &'a MatchResult,
} }
@ -154,6 +141,8 @@ impl Display for MatchResultDisplay<'_> {
#[test] #[test]
fn test_matcher() { fn test_matcher() {
use crate::logline::Exception; use crate::logline::Exception;
use time::OffsetDateTime;
use tinystr::TinyAsciiStr;
const STATEMENTS: &[LoggingStatement] = &[ const STATEMENTS: &[LoggingStatement] = &[
LoggingStatement { LoggingStatement {
@ -201,52 +190,57 @@ fn test_matcher() {
assert_eq!( assert_eq!(
Some(MatchResult::Single(0)), Some(MatchResult::Single(0)),
matcher.match_log(&LogLine { matcher.match_log(&LogLine {
version: "29", version: TinyAsciiStr::from_str("29").unwrap(),
app: "core".into(), app: TinyAsciiStr::from_str("core").unwrap(),
level: LogLevel::Error, level: LogLevel::Error,
message: "Not allowed to rename a shared album".into(), message: "Not allowed to rename a shared album".into(),
exception: None, exception: None,
time: OffsetDateTime::now_utc(),
}) })
); );
assert_eq!( assert_eq!(
Some(MatchResult::List(vec![3, 4])), Some(MatchResult::List(vec![3, 4])),
matcher.match_log(&LogLine { matcher.match_log(&LogLine {
version: "29", version: TinyAsciiStr::from_str("29").unwrap(),
app: "core".into(), app: TinyAsciiStr::from_str("core").unwrap(),
level: LogLevel::Error, level: LogLevel::Error,
message: "Not allowed to rename an album".into(), message: "Not allowed to rename an album".into(),
exception: None, exception: None,
time: OffsetDateTime::now_utc(),
}) })
); );
assert_eq!( assert_eq!(
Some(MatchResult::Single(1)), Some(MatchResult::Single(1)),
matcher.match_log(&LogLine { matcher.match_log(&LogLine {
version: "29", version: TinyAsciiStr::from_str("29").unwrap(),
app: "core".into(), app: TinyAsciiStr::from_str("core").unwrap(),
level: LogLevel::Error, level: LogLevel::Error,
message: "You are not allowed to edit link shares that you don't own".into(), message: "You are not allowed to edit link shares that you don't own".into(),
exception: None, exception: None,
time: OffsetDateTime::now_utc(),
}) })
); );
assert_eq!( assert_eq!(
None, None,
matcher.match_log(&LogLine { matcher.match_log(&LogLine {
version: "29", version: TinyAsciiStr::from_str("29").unwrap(),
app: "core".into(), app: TinyAsciiStr::from_str("core").unwrap(),
level: LogLevel::Info, level: LogLevel::Info,
message: "You are not allowed to edit link shares that you don't own".into(), message: "You are not allowed to edit link shares that you don't own".into(),
exception: None, exception: None,
time: OffsetDateTime::now_utc(),
}) })
); );
assert_eq!( assert_eq!(
Some(MatchResult::Single(2)), Some(MatchResult::Single(2)),
matcher.match_log( matcher.match_log(
&LogLine { &LogLine {
version: "29", version: TinyAsciiStr::from_str("29").unwrap(),
app: "core".into(), app: TinyAsciiStr::from_str("core").unwrap(),
level: LogLevel::Error, level: LogLevel::Error,
message: "Unsupported query value for mimetype: %/text, only values in the format \"mime/type\" or \"mime/%\" are supported".into(), message: "Unsupported query value for mimetype: %/text, only values in the format \"mime/type\" or \"mime/%\" are supported".into(),
exception: None, exception: None,
time: OffsetDateTime::now_utc(),
} }
) )
); );
@ -254,8 +248,8 @@ fn test_matcher() {
Some(MatchResult::Single(4)), Some(MatchResult::Single(4)),
matcher.match_log( matcher.match_log(
&LogLine { &LogLine {
version: "29", version: TinyAsciiStr::from_str("29").unwrap(),
app: "core".into(), app: TinyAsciiStr::from_str("core").unwrap(),
level: LogLevel::Error, level: LogLevel::Error,
message: "Unsupported query value for mimetype: %/text, only values in the format \"mime/type\" or \"mime/%\" are supported".into(), message: "Unsupported query value for mimetype: %/text, only values in the format \"mime/type\" or \"mime/%\" are supported".into(),
exception: Some(Exception { exception: Some(Exception {
@ -264,6 +258,7 @@ fn test_matcher() {
line: 68, line: 68,
previous: None, previous: None,
}), }),
time: OffsetDateTime::now_utc(),
} }
) )
); );

51
src/timegraph.rs Normal file
View file

@ -0,0 +1,51 @@
use crate::logline::LogLine;
use hdrhistogram::Histogram;
use time::OffsetDateTime;
pub struct TimeGraph {
histogram: Histogram<u64>,
start: u64,
end: u64,
}
impl TimeGraph {
pub fn new(start: OffsetDateTime, end: OffsetDateTime) -> Self {
let histogram = Histogram::new_with_bounds(
1,
end.unix_timestamp() as u64 - start.unix_timestamp() as u64 + 1,
4,
)
.unwrap();
TimeGraph {
histogram,
start: start.unix_timestamp() as u64,
end: end.unix_timestamp() as u64,
}
}
pub fn generate(lines: &[LogLine]) -> Self {
let min_time = lines[0].time;
let max_time = lines.last().unwrap().time;
let mut histogram = TimeGraph::new(min_time, max_time);
for line in lines {
histogram.add(line.time);
}
histogram
}
pub fn add(&mut self, time: OffsetDateTime) {
self.histogram
.record(time.unix_timestamp() as u64 - self.start + 1)
.unwrap()
}
pub fn counts(&self, buckets: usize) -> Vec<u64> {
let step = (self.end - self.start + 1) / buckets as u64;
self.histogram
.iter_linear(step)
.map(|val| val.count_since_last_iteration())
.collect()
}
}

View file

@ -19,13 +19,7 @@ pub fn footer(app: &App, page: UiPage) -> Table {
Table::new( Table::new(
[Row::new([ [Row::new([
help(page).to_string(), help(page).to_string(),
format!( format!("{} unmatched items", app.unmatched.len()),
"{} unmatched items",
app.unmatched
.iter()
.map(|unmatched| unmatched.count())
.sum::<usize>()
),
format!("{} parse errors", app.error_count), format!("{} parse errors", app.error_count),
])], ])],
widths, widths,
@ -36,6 +30,6 @@ pub fn footer(app: &App, page: UiPage) -> Table {
fn help(page: UiPage) -> &'static str { fn help(page: UiPage) -> &'static str {
match page { match page {
UiPage::MatchList => "«Q» Exit - «Enter» Select", UiPage::MatchList => "«Q» Exit - «Enter» Select",
UiPage::Match => "«Q» Exit - «Esc» Back", UiPage::Match | UiPage::All | UiPage::Unmatched => "«Q» Exit - «Esc» Back",
} }
} }

44
src/ui/histogram.rs Normal file
View file

@ -0,0 +1,44 @@
use crate::timegraph::TimeGraph;
use ratatui::prelude::*;
use ratatui::widgets::Sparkline;
pub struct UiHistogram<'a> {
data: &'a TimeGraph,
}
impl<'a> UiHistogram<'a> {
pub fn new(data: &'a TimeGraph) -> Self {
UiHistogram { data }
}
}
impl Widget for UiHistogram<'_> {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
let values = self.data.counts(area.width as usize);
let sparkline = Sparkline::default().data(&values);
sparkline.render(area, buf)
}
}
const SPARKS: &[char] = &[' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
pub fn sparkline(values: &[u64]) -> String {
let max = values.iter().copied().max().unwrap() as f64;
let len = SPARKS.len() as f64 - 1.0;
values
.iter()
.copied()
.map(|val| {
let rel = val as f64 / max;
SPARKS[(rel * len) as usize]
})
.collect()
}
#[test]
fn test_sparkline() {
assert_eq!(" ▇█", sparkline(&[0, 900, 1000]));
}

View file

@ -1,31 +1,62 @@
use crate::app::{App, LogMatch}; use crate::app::{App, LogMatch};
use crate::ui::histogram::sparkline;
use crate::ui::style::{TABLE_HEADER_STYLE, TABLE_SELECTED_STYLE}; use crate::ui::style::{TABLE_HEADER_STYLE, TABLE_SELECTED_STYLE};
use itertools::Either;
use ratatui::prelude::*; use ratatui::prelude::*;
use ratatui::widgets::{Cell, HighlightSpacing, Row, Table}; use ratatui::widgets::{Cell, HighlightSpacing, Row, Table};
use std::fmt::Write; use std::fmt::Write;
use std::iter::{empty, once};
pub fn match_list(app: &App) -> Table { pub fn match_list(app: &App) -> Table {
let header = ["Statement", "File", "Line", "Count"] let header = [
.into_iter() Text::from("Statement"),
.map(Cell::from) Text::from("File"),
.collect::<Row>() Text::from("Line").alignment(Alignment::Right),
.style(TABLE_HEADER_STYLE) Text::from("Time"),
.height(1); Text::from("Count"),
]
.into_iter()
.map(Cell::from)
.collect::<Row>()
.style(TABLE_HEADER_STYLE)
.height(1);
let widths = [ let widths = [
Constraint::Percentage(60), Constraint::Percentage(70),
Constraint::Percentage(40), Constraint::Percentage(30),
Constraint::Min(10), Constraint::Min(6),
Constraint::Length(10),
Constraint::Min(10), Constraint::Min(10),
]; ];
let table = Table::new(
app.matches.iter().map(|result| log_row(result, app)), let all = Row::new([
Text::from("All lines"),
Text::from(String::new()),
Text::from(String::new()).alignment(Alignment::Right),
Text::from(sparkline(&app.histogram.counts(10))),
Text::from(app.lines.len().to_string()),
]);
let unmatched = if app.unmatched.is_empty() {
Either::Right(empty())
} else {
Either::Left(once(Row::new([
Text::from("Unmatched lines"),
Text::from(String::new()),
Text::from(String::new()).alignment(Alignment::Right),
Text::from(sparkline(&app.unmatched_histogram.counts(10))),
Text::from(app.unmatched.len().to_string()),
])))
};
Table::new(
once(all)
.chain(app.matches.iter().map(|result| log_row(result, app)))
.chain(unmatched),
widths, widths,
) )
.header(header) .header(header)
.highlight_style(TABLE_SELECTED_STYLE) .highlight_style(TABLE_SELECTED_STYLE)
.highlight_spacing(HighlightSpacing::Always); .highlight_spacing(HighlightSpacing::Always)
table
} }
fn log_row<'a>(result: &LogMatch, app: &'a App) -> Row<'a> { fn log_row<'a>(result: &LogMatch, app: &'a App) -> Row<'a> {
@ -38,5 +69,12 @@ fn log_row<'a>(result: &LogMatch, app: &'a App) -> Row<'a> {
writeln!(&mut paths, "{}", statement.path()).unwrap(); writeln!(&mut paths, "{}", statement.path()).unwrap();
writeln!(&mut lines, "{}", statement.line).unwrap(); writeln!(&mut lines, "{}", statement.line).unwrap();
} }
Row::new([message, paths, lines, result.count().to_string()]).height(result.result.len() as u16) Row::new([
Text::from(message),
Text::from(paths),
Text::from(lines).alignment(Alignment::Right),
Text::from(sparkline(&result.histogram.counts(10))),
Text::from(result.count().to_string()),
])
.height(result.result.len() as u16)
} }

View file

@ -1,8 +1,9 @@
use crate::app::App; use crate::app::App;
use crate::error::UiError; use crate::error::UiError;
use crate::ui::footer::footer; use crate::ui::footer::footer;
use crate::ui::histogram::UiHistogram;
use crate::ui::match_list::match_list; use crate::ui::match_list::match_list;
use crate::ui::single_match::single_match; use crate::ui::single_match::grouped_lines;
use crate::ui::state::{UiEvent, UiState}; use crate::ui::state::{UiEvent, UiState};
use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers}; use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers};
use ratatui::crossterm::terminal::{ use ratatui::crossterm::terminal::{
@ -15,6 +16,7 @@ use std::io;
use std::io::stdout; use std::io::stdout;
mod footer; mod footer;
mod histogram;
mod match_list; mod match_list;
mod single_match; mod single_match;
mod state; mod state;
@ -48,10 +50,12 @@ fn handle_events() -> io::Result<Option<UiEvent>> {
Some(UiEvent::Quit) Some(UiEvent::Quit)
} }
KeyCode::Char('q') => Some(UiEvent::Quit), KeyCode::Char('q') => Some(UiEvent::Quit),
KeyCode::Esc => Some(UiEvent::Back), KeyCode::Esc | KeyCode::Left => Some(UiEvent::Back),
KeyCode::Down => Some(UiEvent::Down), KeyCode::Down => Some(UiEvent::Down(1)),
KeyCode::Up => Some(UiEvent::Up), KeyCode::Up => Some(UiEvent::Up(1)),
KeyCode::Enter => Some(UiEvent::Select), KeyCode::PageDown => Some(UiEvent::Down(10)),
KeyCode::PageUp => Some(UiEvent::Up(10)),
KeyCode::Enter | KeyCode::Right => Some(UiEvent::Select),
_ => None, _ => None,
}); });
} }
@ -64,22 +68,48 @@ fn ui(frame: &mut Frame, app: &App, state: &mut UiState) {
let page = state.page(); let page = state.page();
let layout = Layout::default() let layout = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints(vec![Constraint::Percentage(100), Constraint::Length(1)]) .constraints(vec![
Constraint::Min(5),
Constraint::Percentage(100),
Constraint::Length(1),
])
.split(frame.size()); .split(frame.size());
match state { match state {
UiState::Quit => {} UiState::Quit => {}
UiState::MatchList { table_state } => { UiState::MatchList { table_state } => {
frame.render_stateful_widget(match_list(app), layout[0], table_state); let selected = table_state.selected().unwrap_or(0);
frame.render_widget(footer(app, page), layout[1]); let histogram = if selected == 0 {
&app.histogram
} else if selected < app.matches.len() + 1 {
let log_match = &app.matches[selected - 1];
&log_match.histogram
} else {
&app.unmatched_histogram
};
frame.render_widget(UiHistogram::new(histogram), layout[0]);
frame.render_stateful_widget(match_list(app), layout[1], table_state);
frame.render_widget(footer(app, page), layout[2]);
} }
UiState::Match { UiState::Match {
selected: index, selected: index,
table_state, table_state,
} => { } => {
let log_match = &app.matches[*index]; let log_match = &app.matches[*index];
frame.render_stateful_widget(single_match(app, log_match), layout[0], table_state); let lines = log_match.lines.iter().map(|i| &app.lines[*i]);
frame.render_widget(footer(app, page), layout[1]);
frame.render_widget(UiHistogram::new(&log_match.histogram), layout[0]);
frame.render_stateful_widget(grouped_lines(lines), layout[1], table_state);
frame.render_widget(footer(app, page), layout[2]);
}
UiState::All { table_state } => {
frame.render_stateful_widget(grouped_lines(app.lines.iter()), layout[1], table_state);
frame.render_widget(footer(app, page), layout[2]);
}
UiState::Unmatched { table_state } => {
let lines = app.unmatched.iter().map(|i| &app.lines[*i]);
frame.render_stateful_widget(grouped_lines(lines), layout[1], table_state);
frame.render_widget(footer(app, page), layout[2]);
} }
} }
} }

View file

@ -1,13 +1,10 @@
use crate::app::{App, LogMatch};
use crate::logline::LogLine; use crate::logline::LogLine;
use crate::ui::style::{TABLE_HEADER_STYLE, TABLE_SELECTED_STYLE, TIME_FORMAT}; use crate::ui::style::{TABLE_HEADER_STYLE, TABLE_SELECTED_STYLE, TIME_FORMAT};
use ratatui::layout::Constraint; use ratatui::layout::Constraint;
use ratatui::widgets::{Cell, HighlightSpacing, Row, Table}; use ratatui::widgets::{Cell, HighlightSpacing, Row, Table};
use time::format_description::well_known::Iso8601; use time::format_description::well_known::Iso8601;
pub fn single_match<'a>(app: &'a App, matches: &'a LogMatch) -> Table<'a> { pub fn grouped_lines<'a, I: Iterator<Item = &'a LogLine> + 'a>(lines: I) -> Table<'a> {
let lines = matches.lines.iter().map(|i| &app.lines[*i]);
let header = ["Level", "App", "Message", "Date"] let header = ["Level", "App", "Message", "Date"]
.into_iter() .into_iter()
.map(Cell::from) .map(Cell::from)

View file

@ -11,6 +11,12 @@ pub enum UiState {
selected: usize, selected: usize,
table_state: TableState, table_state: TableState,
}, },
All {
table_state: TableState,
},
Unmatched {
table_state: TableState,
},
Quit, Quit,
} }
@ -27,6 +33,28 @@ impl UiState {
match self { match self {
UiState::Quit | UiState::MatchList { .. } => UiPage::MatchList, UiState::Quit | UiState::MatchList { .. } => UiPage::MatchList,
UiState::Match { .. } => UiPage::Match, UiState::Match { .. } => UiPage::Match,
UiState::All { .. } => UiPage::All,
UiState::Unmatched { .. } => UiPage::Unmatched,
}
}
fn table_state(&mut self) -> Option<&mut TableState> {
match self {
UiState::MatchList { table_state } => Some(table_state),
UiState::Match { table_state, .. } => Some(table_state),
UiState::All { table_state } => Some(table_state),
UiState::Unmatched { table_state } => Some(table_state),
UiState::Quit => None,
}
}
fn table_count(&self, app: &App) -> usize {
match self {
UiState::MatchList { .. } => app.match_lines(),
UiState::Match { selected, .. } => app.matches[*selected].count(),
UiState::All { .. } => app.lines.len(),
UiState::Unmatched { .. } => app.unmatched.len(),
UiState::Quit => 0,
} }
} }
@ -35,21 +63,33 @@ impl UiState {
(UiState::Quit, _) => UiState::Quit, (UiState::Quit, _) => UiState::Quit,
(_, UiEvent::Quit) => UiState::Quit, (_, UiEvent::Quit) => UiState::Quit,
(UiState::MatchList { .. }, UiEvent::Back) => UiState::Quit, (UiState::MatchList { .. }, UiEvent::Back) => UiState::Quit,
(UiState::MatchList { mut table_state }, UiEvent::Down) => { (mut state, UiEvent::Down(step)) => {
table_state.down(app.matches.len()); let count = state.table_count(app);
UiState::MatchList { table_state } if let Some(table_state) = state.table_state() {
table_state.down(count, step)
}
state
} }
(UiState::MatchList { mut table_state }, UiEvent::Up) => { (mut state, UiEvent::Up(step)) => {
table_state.up(app.matches.len()); let count = state.table_count(app);
UiState::MatchList { table_state } if let Some(table_state) = state.table_state() {
table_state.up(count, step)
}
state
} }
(UiState::MatchList { table_state }, UiEvent::Select) => { (UiState::MatchList { table_state }, UiEvent::Select) => {
let selected = table_state.selected().unwrap_or(0); let selected = table_state.selected().unwrap_or(0);
let mut table_state = TableState::default(); let mut table_state = TableState::default();
table_state.select(Some(0)); table_state.select(Some(0));
UiState::Match { if selected == 0 {
selected, UiState::All { table_state }
table_state, } else if selected == app.match_lines() - 1 {
UiState::Unmatched { table_state }
} else {
UiState::Match {
selected: selected - 1,
table_state,
}
} }
} }
( (
@ -59,36 +99,20 @@ impl UiState {
UiEvent::Back, UiEvent::Back,
) => { ) => {
let mut table_state = TableState::default(); let mut table_state = TableState::default();
table_state.select(Some(index)); table_state.select(Some(index + 1));
UiState::MatchList { table_state } UiState::MatchList { table_state }
} }
( (UiState::All { .. }, UiEvent::Back) => {
UiState::Match { let mut table_state = TableState::default();
mut table_state, table_state.select(Some(0));
selected, UiState::MatchList { table_state }
},
UiEvent::Down,
) => {
table_state.down(app.matches[selected].count());
UiState::Match {
table_state,
selected,
}
} }
( (UiState::Unmatched { .. }, UiEvent::Back) => {
UiState::Match { let mut table_state = TableState::default();
mut table_state, table_state.select(Some(app.match_lines() - 1));
selected, UiState::MatchList { table_state }
},
UiEvent::Up,
) => {
table_state.up(app.matches[selected].count());
UiState::Match {
table_state,
selected,
}
} }
(state @ UiState::Match { .. }, _) => state, (state, _) => state,
} }
} }
} }
@ -96,33 +120,45 @@ impl UiState {
pub enum UiEvent { pub enum UiEvent {
Quit, Quit,
Back, Back,
Up, Up(usize),
Down, Down(usize),
Select, Select,
} }
pub enum UiPage { pub enum UiPage {
MatchList, MatchList,
Match, Match,
All,
Unmatched,
} }
mod table_state { mod table_state {
use ratatui::widgets::TableState; use ratatui::widgets::TableState;
pub trait TableStateExt { pub trait TableStateExt {
fn up(&mut self, count: usize); fn up(&mut self, count: usize, step: usize);
fn down(&mut self, count: usize); fn down(&mut self, count: usize, step: usize);
} }
impl TableStateExt for TableState { impl TableStateExt for TableState {
fn up(&mut self, count: usize) { fn up(&mut self, count: usize, step: usize) {
let current = self.selected().unwrap_or(0); let current = self.selected().unwrap_or(0);
self.select(Some(if current == 0 { count - 1 } else { current - 1 })) let after = if step > current {
count - 1
} else {
current - step
};
self.select(Some(after))
} }
fn down(&mut self, count: usize) { fn down(&mut self, count: usize, step: usize) {
let current = self.selected().unwrap_or(0); let current = self.selected().unwrap_or(0);
self.select(Some((current + 1).rem_euclid(count))) let after = if step >= count - current {
0
} else {
current + step
};
self.select(Some(after))
} }
} }
} }