This commit is contained in:
Jana Dönszelmann 2026-04-03 18:07:35 +02:00
parent bb8f6bedf6
commit 4a6c1020f4
No known key found for this signature in database
10 changed files with 113 additions and 34 deletions

17
Cargo.lock generated
View file

@ -228,6 +228,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "color-ansi"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e56460573dab12b4bb6dfdb0abbb021e35d6a2bbd925acd2f5a9accff5654a8e"
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.4" version = "1.0.4"
@ -1163,6 +1169,16 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "pretty-print"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5ad516586f2191e7ce412b9b164e61ee6403638aa70e98672b978c1f448e63f"
dependencies = [
"color-ansi",
"unicode-segmentation",
]
[[package]] [[package]]
name = "prettyplease" name = "prettyplease"
version = "0.2.37" version = "0.2.37"
@ -1430,6 +1446,7 @@ dependencies = [
"jiff", "jiff",
"logparse", "logparse",
"nix 0.31.1", "nix 0.31.1",
"pretty-print",
"ratatui", "ratatui",
"ratatui-themes", "ratatui-themes",
"regex", "regex",

View file

@ -26,3 +26,4 @@ regex = "1"
crossterm = "*" crossterm = "*"
dumpster = "2.1" dumpster = "2.1"
logparse = {path = "./logparse/", version="0.2.0"} logparse = {path = "./logparse/", version="0.2.0"}
pretty-print = "0.1"

View file

@ -46,9 +46,13 @@ pub struct Span<'a> {
} }
/// Configuration options for [`into_spans`] /// Configuration options for [`into_spans`]
#[derive(Default)]
#[non_exhaustive]
pub struct Config { pub struct Config {
/// Turn sequences of more than 1 space into exactly 1 space. /// Turn sequences of more than 1 space into exactly 1 space.
pub collapse_space: bool, pub collapse_space: bool,
/// Pretty print: wrap at braces etc
pub pretty_print: bool,
} }
pub trait IntoSpans<'a>: private::IntoSpansImpl<'a> {} pub trait IntoSpans<'a>: private::IntoSpansImpl<'a> {}

View file

@ -6,7 +6,7 @@ use crate::tui::{
LogViewer, LogViewer,
input::{FieldMatcher, InputTarget}, input::{FieldMatcher, InputTarget},
}, },
model::{FieldsName, LogEntry}, model::{LogEntry, SpanDescriptor},
}; };
mod serialize_regex { mod serialize_regex {
@ -56,7 +56,7 @@ impl MatcherValue {
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub enum Matcher { pub enum Matcher {
Field { Field {
span: FieldsName, span: SpanDescriptor,
name: String, name: String,
value: MatcherValue, value: MatcherValue,
}, },

View file

@ -7,7 +7,7 @@ use crate::tui::{
input::{FieldMatcher, InputState, InputTarget}, input::{FieldMatcher, InputState, InputTarget},
view::LogView, view::LogView,
}, },
model::{FieldsName, LogEntry, id}, model::{LogEntry, SpanDescriptor, id},
processing::Cursor, processing::Cursor,
widgets::{fieldtree, last_error::LastError}, widgets::{fieldtree, last_error::LastError},
}; };
@ -54,7 +54,7 @@ impl LogViewer {
} }
} }
pub fn get_selected_field(&self) -> Option<(FieldsName, String, String)> { pub fn get_selected_field(&self) -> Option<(SpanDescriptor, String, String)> {
let (span_idx, name_idx) = self.field_state.get()?; let (span_idx, name_idx) = self.field_state.get()?;
let entry = self.selected().map(|(s, _)| s)?; let entry = self.selected().map(|(s, _)| s)?;

View file

@ -5,6 +5,8 @@ use crossterm::{
}, },
terminal::{EnterAlternateScreen, LeaveAlternateScreen}, terminal::{EnterAlternateScreen, LeaveAlternateScreen},
}; };
use itertools::Itertools;
use logparse::{self as lp, Config, into_spans, parse_input};
use ratatui_themes::{Color, Theme, ThemeName}; use ratatui_themes::{Color, Theme, ThemeName};
use std::{ use std::{
borrow::Cow, borrow::Cow,
@ -23,10 +25,11 @@ use crate::tui::{
LogViewer, LogViewer,
input::{FieldMatcher, InputState, InputTarget}, input::{FieldMatcher, InputState, InputTarget},
}, },
model::SpanDescriptor,
reader::LogfileReader, reader::LogfileReader,
widgets::{ widgets::{
fieldtree::FieldTree, hyperlink::Hyperlink, items::Items, last_error::LastError, fieldtree::FieldTree, hyperlink::Hyperlink, items::Items, last_error::LastError,
styled::IntoStyled, line_text::style_span, styled::IntoStyled,
}, },
}; };
use ratatui::{ use ratatui::{
@ -37,7 +40,7 @@ use ratatui::{
prelude::CrosstermBackend, prelude::CrosstermBackend,
style::Style, style::Style,
symbols::merge::MergeStrategy, symbols::merge::MergeStrategy,
text::Line, text::{Line, Span},
widgets::{ widgets::{
Block, Clear, List, ListItem, ListState, Padding, Paragraph, StatefulWidget, Widget, Wrap, Block, Clear, List, ListItem, ListState, Padding, Paragraph, StatefulWidget, Widget, Wrap,
}, },
@ -64,6 +67,7 @@ const HELP_TEXT: &str = "Generic:
-> / enter / l enter nested view -> / enter / l enter nested view
f toggle show active filters f toggle show active filters
z zoom in on a selected field
targeting logs: targeting logs:
@ -127,6 +131,11 @@ enum Tab {
LogViewer(LogViewer), LogViewer(LogViewer),
Empty, Empty,
Help, Help,
Zoom {
span: SpanDescriptor,
name: String,
value: String,
},
} }
impl Tab { impl Tab {
@ -137,6 +146,11 @@ impl Tab {
(Tab::LogViewer(_), Some(path)) => format!("logs of {}", path.display()), (Tab::LogViewer(_), Some(path)) => format!("logs of {}", path.display()),
(Tab::LogViewer(_), None) => "logs".to_string(), (Tab::LogViewer(_), None) => "logs".to_string(),
(Tab::Help, _) => "help".to_string(), (Tab::Help, _) => "help".to_string(),
(Tab::Zoom { span, name, .. }, _) => match span {
SpanDescriptor::Main => format!("{name}"),
SpanDescriptor::Span(s) => format!("{name} in {s}"),
SpanDescriptor::Numbered(n) => format!("{name} in span #{n}"),
},
} }
} }
} }
@ -244,6 +258,7 @@ impl App {
match self.tabs.last_mut().unwrap() { match self.tabs.last_mut().unwrap() {
Tab::Help => {} Tab::Help => {}
Tab::Empty => {} Tab::Empty => {}
Tab::Zoom { .. } => {}
Tab::FileChooser { Tab::FileChooser {
files, files,
state, state,
@ -327,6 +342,11 @@ impl App {
KeyCode::Char('u') => lv.undo(), KeyCode::Char('u') => lv.undo(),
KeyCode::Char('r') => lv.redo(), KeyCode::Char('r') => lv.redo(),
KeyCode::Char('z') => {
if let Some((span, name, value)) = lv.get_selected_field() {
self.push_tab(Tab::Zoom { span, name, value });
}
}
KeyCode::Tab => { KeyCode::Tab => {
lv.input_state.target(InputTarget::Fields(None)); lv.input_state.target(InputTarget::Fields(None));
@ -592,6 +612,7 @@ impl Widget for &mut App {
let (footer_focused, header_focused) = match self.current_tab() { let (footer_focused, header_focused) = match self.current_tab() {
Tab::Help => (false, false), Tab::Help => (false, false),
Tab::Zoom { .. } => (false, false),
Tab::FileChooser { .. } => (false, true), Tab::FileChooser { .. } => (false, true),
Tab::LogViewer(lv) => { Tab::LogViewer(lv) => {
let target_fields = let target_fields =
@ -716,6 +737,42 @@ impl Widget for &mut App {
Paragraph::new(HELP_TEXT).render(popup_area, buf); Paragraph::new(HELP_TEXT).render(popup_area, buf);
} }
Tab::Zoom { value, .. } => {
Clear.render(popup_area, buf);
let popup_area = {
let block = Block::bordered()
.title_top("zoom")
.style(styles.default)
.padding(Padding::symmetric(3, 1))
.border_style(styles.border_highlighted);
let inner = block.inner(popup_area);
block.render(popup_area, buf);
inner
};
if let Ok(parsed) = parse_input(&value) {
let spans = into_spans(
parsed,
Config {
collapse_space: true,
},
);
let spans = spans
.into_iter()
.map(|lp::Span { text, kind }| {
let span = Span::from(text.into_owned());
let style = style_span(kind, styles.default, &styles);
span.style(style)
})
.collect_vec();
Paragraph::new(Line::from(spans)).render(popup_area, buf);
} else {
Paragraph::new(value.clone()).render(popup_area, buf);
}
}
} }
self.last_error.clone().styled(&styles).render(error, buf); self.last_error.clone().styled(&styles).render(error, buf);

View file

@ -396,7 +396,7 @@ unsafe impl<V: Visitor> TraceWith<V> for RawLogEntry {
} }
#[derive(PartialEq, Eq, Debug, Hash, Clone, PartialOrd, Ord, Serialize, Deserialize)] #[derive(PartialEq, Eq, Debug, Hash, Clone, PartialOrd, Ord, Serialize, Deserialize)]
pub enum FieldsName { pub enum SpanDescriptor {
/// The main fields (not of a span) of a log entry /// The main fields (not of a span) of a log entry
Main, Main,
Span(String), Span(String),
@ -409,23 +409,23 @@ pub struct SpansRef<'a> {
} }
impl<'a> SpansRef<'a> { impl<'a> SpansRef<'a> {
pub fn find(&self, span: &FieldsName, name: &str) -> Option<&String> { pub fn find(&self, span: &SpanDescriptor, name: &str) -> Option<&String> {
self.named() self.named()
.find(|(name, _)| name == span) .find(|(name, _)| name == span)
.and_then(|(_, fields)| fields.get(name)) .and_then(|(_, fields)| fields.get(name))
} }
pub fn named(&self) -> impl Iterator<Item = (FieldsName, &'a LogFields)> { pub fn named(&self) -> impl Iterator<Item = (SpanDescriptor, &'a LogFields)> {
iter::once((FieldsName::Main, self.main)).chain(self.spans.iter().rev().enumerate().map( iter::once((SpanDescriptor::Main, self.main)).chain(
|(idx, fields)| { self.spans.iter().rev().enumerate().map(|(idx, fields)| {
( (
fields fields
.name() .name()
.map(|i| FieldsName::Span(i.to_string())) .map(|i| SpanDescriptor::Span(i.to_string()))
.unwrap_or_else(|| FieldsName::Numbered(idx)), .unwrap_or_else(|| SpanDescriptor::Numbered(idx)),
fields, fields,
) )
}, }),
)) )
} }
} }

View file

@ -167,7 +167,7 @@ mod tests {
use crate::tui::{ use crate::tui::{
filter::{Filter, FilterKind, Matcher, MatcherValue}, filter::{Filter, FilterKind, Matcher, MatcherValue},
log_viewer::filters::Filters, log_viewer::filters::Filters,
model::FieldsName, model::SpanDescriptor,
processing::Cursor, processing::Cursor,
widgets::last_error::LastError, widgets::last_error::LastError,
}; };
@ -311,7 +311,7 @@ mod tests {
matcher: Matcher::Field { matcher: Matcher::Field {
name: "message".to_string(), name: "message".to_string(),
value: MatcherValue::Exact("foo".to_string()), value: MatcherValue::Exact("foo".to_string()),
span: FieldsName::Main, span: SpanDescriptor::Main,
}, },
kind: FilterKind::Remove, kind: FilterKind::Remove,
})); }));
@ -337,7 +337,7 @@ mod tests {
matcher: Matcher::Field { matcher: Matcher::Field {
name: "message".to_string(), name: "message".to_string(),
value: MatcherValue::Exact("baz".to_string()), value: MatcherValue::Exact("baz".to_string()),
span: FieldsName::Main, span: SpanDescriptor::Main,
}, },
kind: FilterKind::Remove, kind: FilterKind::Remove,
})); }));
@ -368,7 +368,7 @@ mod tests {
matcher: Matcher::Field { matcher: Matcher::Field {
name: "name".to_string(), name: "name".to_string(),
value: MatcherValue::Exact("nest".to_string()), value: MatcherValue::Exact("nest".to_string()),
span: FieldsName::Main, span: SpanDescriptor::Main,
}, },
kind: FilterKind::Inline, kind: FilterKind::Inline,
})); }));
@ -415,7 +415,7 @@ mod tests {
matcher: Matcher::Field { matcher: Matcher::Field {
name: "name".to_string(), name: "name".to_string(),
value: MatcherValue::Exact("nest".to_string()), value: MatcherValue::Exact("nest".to_string()),
span: FieldsName::Main, span: SpanDescriptor::Main,
}, },
kind: FilterKind::Inline, kind: FilterKind::Inline,
})); }));
@ -465,7 +465,7 @@ mod tests {
matcher: Matcher::Field { matcher: Matcher::Field {
name: "name".to_string(), name: "name".to_string(),
value: MatcherValue::Exact("nest1".to_string()), value: MatcherValue::Exact("nest1".to_string()),
span: FieldsName::Main, span: SpanDescriptor::Main,
}, },
kind: FilterKind::Inline, kind: FilterKind::Inline,
})); }));
@ -473,7 +473,7 @@ mod tests {
matcher: Matcher::Field { matcher: Matcher::Field {
name: "name".to_string(), name: "name".to_string(),
value: MatcherValue::Exact("nest2".to_string()), value: MatcherValue::Exact("nest2".to_string()),
span: FieldsName::Main, span: SpanDescriptor::Main,
}, },
kind: FilterKind::Inline, kind: FilterKind::Inline,
})); }));

View file

@ -15,7 +15,7 @@ use tui_widget_list::{ListBuilder, ListState, ListView};
use crate::tui::{ use crate::tui::{
block_around, block_around,
log_viewer::LogViewer, log_viewer::LogViewer,
model::{FieldsName, LogEntry, LogFields}, model::{SpanDescriptor, LogEntry, LogFields},
widgets::{line_text::style_span, styled::Styled}, widgets::{line_text::style_span, styled::Styled},
}; };
@ -91,7 +91,7 @@ impl<'a> FieldTree<'a> {
self.lv.selected().map(|(s, _)| s) self.lv.selected().map(|(s, _)| s)
} }
fn current_span_fields(&mut self, spans: &[(FieldsName, &LogFields)]) -> Vec<(String, String)> { fn current_span_fields(&mut self, spans: &[(SpanDescriptor, &LogFields)]) -> Vec<(String, String)> {
let mut selected = self.state().current_span.selected.unwrap_or(0); let mut selected = self.state().current_span.selected.unwrap_or(0);
if selected > spans.len() { if selected > spans.len() {
selected = spans.len().saturating_sub(1); selected = spans.len().saturating_sub(1);
@ -120,7 +120,7 @@ impl<'a> FieldTree<'a> {
} }
impl Styled<'_, &mut FieldTree<'_>> { impl Styled<'_, &mut FieldTree<'_>> {
fn areas(&self, area: Rect, buf: &mut Buffer, name: Option<&FieldsName>) -> (Rect, Rect, Rect) { fn areas(&self, area: Rect, buf: &mut Buffer, name: Option<&SpanDescriptor>) -> (Rect, Rect, Rect) {
let [spans_area, fields_area] = Layout::horizontal([ let [spans_area, fields_area] = Layout::horizontal([
Constraint::Length(if self.spans_focussed() || !self.focussed { Constraint::Length(if self.spans_focussed() || !self.focussed {
25 25
@ -146,9 +146,9 @@ impl Styled<'_, &mut FieldTree<'_>> {
self.styles, self.styles,
Some(if let Some(i) = name { Some(if let Some(i) = name {
match i { match i {
FieldsName::Main => "own fields".to_string(), SpanDescriptor::Main => "own fields".to_string(),
FieldsName::Span(s) => format!("fields of {s}"), SpanDescriptor::Span(s) => format!("fields of {s}"),
FieldsName::Numbered(idx) => format!("fields of span #{idx}"), SpanDescriptor::Numbered(idx) => format!("fields of span #{idx}"),
} }
} else { } else {
"fields".to_string() "fields".to_string()
@ -191,9 +191,9 @@ impl Widget for Styled<'_, &mut FieldTree<'_>> {
}; };
let mut item = Line::from(match span_name { let mut item = Line::from(match span_name {
FieldsName::Main => "own fields".to_string(), SpanDescriptor::Main => "own fields".to_string(),
FieldsName::Span(s) => s.to_string(), SpanDescriptor::Span(s) => s.to_string(),
FieldsName::Numbered(idx) => format!("span #{idx}"), SpanDescriptor::Numbered(idx) => format!("span #{idx}"),
}); });
if cx.is_selected && self_focussed { if cx.is_selected && self_focussed {
item = item.style(self.styles.highlighted); item = item.style(self.styles.highlighted);

View file

@ -9,7 +9,7 @@ use crate::tui::{
filters::Filters, filters::Filters,
input::{FieldMatcher, InputState, InputTarget}, input::{FieldMatcher, InputState, InputTarget},
}, },
model::{FieldsName, LogEntry}, model::{SpanDescriptor, LogEntry},
widgets::{ widgets::{
last_error::LastError, last_error::LastError,
line_text::Highlighted, line_text::Highlighted,
@ -23,7 +23,7 @@ pub struct Items<'a> {
input_state: &'a InputState, input_state: &'a InputState,
filters: &'a Filters, filters: &'a Filters,
selected_footer_field: Option<(FieldsName, String, String)>, selected_footer_field: Option<(SpanDescriptor, String, String)>,
last_error: LastError, last_error: LastError,
} }
@ -33,7 +33,7 @@ impl<'a> Items<'a> {
filters: &'a Filters, filters: &'a Filters,
selected_offset: usize, selected_offset: usize,
input_state: &'a InputState, input_state: &'a InputState,
selected_footer_field: Option<(FieldsName, String, String)>, selected_footer_field: Option<(SpanDescriptor, String, String)>,
last_error: LastError, last_error: LastError,
) -> Self { ) -> Self {
Self { Self {