set envvars
This commit is contained in:
commit
3963fc50c3
9 changed files with 3248 additions and 0 deletions
158
src/tui/filter.rs
Normal file
158
src/tui/filter.rs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
use crate::tui::model::LogEntry;
|
||||
|
||||
pub enum WipMatcher {
|
||||
Field {
|
||||
name: Option<String>,
|
||||
value: Option<serde_json::Value>,
|
||||
},
|
||||
Specific {
|
||||
path: Option<Vec<usize>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl WipMatcher {
|
||||
fn validate(&self) -> Option<Matcher> {
|
||||
match self {
|
||||
WipMatcher::Field {
|
||||
name: Some(name),
|
||||
value: Some(value),
|
||||
} => Some(Matcher::Field {
|
||||
name: name.clone(),
|
||||
value: value.clone(),
|
||||
}),
|
||||
WipMatcher::Specific { path: Some(path) } => {
|
||||
Some(Matcher::Specific { path: path.clone() })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Matcher {
|
||||
Field {
|
||||
name: String,
|
||||
value: serde_json::Value,
|
||||
},
|
||||
Specific {
|
||||
path: Vec<usize>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Matcher {
|
||||
pub fn matches(&self, entry: &LogEntry) -> bool {
|
||||
match self {
|
||||
Matcher::Field { name, value } => entry
|
||||
.all_fields()
|
||||
.fields
|
||||
.get(name)
|
||||
.is_some_and(|v| v == value),
|
||||
Matcher::Specific { path } => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum FilterKind {
|
||||
Inline,
|
||||
Remove,
|
||||
}
|
||||
|
||||
pub struct Filter {
|
||||
pub matcher: Matcher,
|
||||
pub kind: FilterKind,
|
||||
}
|
||||
|
||||
pub struct WipFilter {
|
||||
pub matcher: Option<WipMatcher>,
|
||||
pub kind: Option<FilterKind>,
|
||||
pub selection: FilterSelection,
|
||||
}
|
||||
|
||||
impl WipFilter {
|
||||
pub fn validate(&self) -> Option<Filter> {
|
||||
let Self {
|
||||
matcher,
|
||||
kind,
|
||||
selection: _,
|
||||
} = self;
|
||||
let Some(matcher) = matcher else { return None };
|
||||
Some(Filter {
|
||||
matcher: matcher.validate()?,
|
||||
kind: kind.clone()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
match self.selection {
|
||||
FilterSelection::Kind => self.kind = None,
|
||||
FilterSelection::MatcherKind => {}
|
||||
FilterSelection::Matcher => {}
|
||||
FilterSelection::Confirm => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn right(&mut self) {
|
||||
match self.selection {
|
||||
FilterSelection::Kind => {
|
||||
self.kind = Some(match self.kind {
|
||||
None => FilterKind::Inline,
|
||||
Some(FilterKind::Inline) => FilterKind::Remove,
|
||||
Some(FilterKind::Remove) => FilterKind::Inline,
|
||||
})
|
||||
}
|
||||
FilterSelection::MatcherKind => {}
|
||||
FilterSelection::Matcher => {}
|
||||
FilterSelection::Confirm => {}
|
||||
}
|
||||
}
|
||||
pub fn left(&mut self) {
|
||||
match self.selection {
|
||||
FilterSelection::Kind => {
|
||||
self.kind = Some(match self.kind {
|
||||
None => FilterKind::Remove,
|
||||
Some(FilterKind::Remove) => FilterKind::Inline,
|
||||
Some(FilterKind::Inline) => FilterKind::Inline,
|
||||
})
|
||||
}
|
||||
FilterSelection::MatcherKind => {}
|
||||
FilterSelection::Matcher => {}
|
||||
FilterSelection::Confirm => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum FilterSelection {
|
||||
Kind,
|
||||
MatcherKind,
|
||||
Matcher,
|
||||
Confirm,
|
||||
}
|
||||
|
||||
impl FilterSelection {
|
||||
pub fn next(&mut self) {
|
||||
*self = match *self {
|
||||
Self::Kind => Self::MatcherKind,
|
||||
Self::MatcherKind => Self::Matcher,
|
||||
Self::Matcher => Self::Confirm,
|
||||
Self::Confirm => Self::Confirm,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn prev(&mut self) {
|
||||
*self = match self {
|
||||
Self::Kind => Self::Kind,
|
||||
Self::MatcherKind => Self::Kind,
|
||||
Self::Matcher => Self::MatcherKind,
|
||||
Self::Confirm => Self::Matcher,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum FieldMatcherSelection {
|
||||
Field,
|
||||
Value,
|
||||
}
|
||||
|
||||
impl FieldMatcherSelection {}
|
||||
166
src/tui/log_viewer.rs
Normal file
166
src/tui/log_viewer.rs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
use std::{collections::HashMap, mem, rc::Rc};
|
||||
|
||||
use crate::tui::{
|
||||
model::LogEntry,
|
||||
reader::{FilterAdapter, LogfileReader},
|
||||
};
|
||||
use tui_widget_list::ListState;
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct LogView {
|
||||
first_item: usize,
|
||||
selected: usize,
|
||||
}
|
||||
|
||||
pub struct LogViewer {
|
||||
stack: Vec<LogView>,
|
||||
curr: LogView,
|
||||
cache: HashMap<Vec<usize>, LogView>,
|
||||
|
||||
pub last_height: usize,
|
||||
pub footer_selected: bool,
|
||||
pub footer_list: ListState,
|
||||
}
|
||||
|
||||
impl LogViewer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
stack: Vec::new(),
|
||||
curr: LogView::default(),
|
||||
cache: HashMap::new(),
|
||||
footer_list: ListState::default(),
|
||||
footer_selected: false,
|
||||
last_height: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_num_items(&mut self, num_visible_items: usize) {
|
||||
while self.curr.selected >= self.curr.first_item + num_visible_items {
|
||||
self.curr.first_item += 1;
|
||||
}
|
||||
self.last_height = num_visible_items;
|
||||
}
|
||||
|
||||
pub fn footer_fields(&self, file: &mut LogfileReader) -> Vec<(String, serde_json::Value)> {
|
||||
if let Some(selected) = self.selected(file) {
|
||||
selected.all_fields().fields.into_iter().collect::<Vec<_>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn items(
|
||||
&self,
|
||||
file: &mut LogfileReader,
|
||||
max: usize,
|
||||
) -> Option<(Vec<Rc<LogEntry>>, usize, usize)> {
|
||||
let items: Vec<_> = if self.stack.is_empty() {
|
||||
file.iter_from(self.curr.first_item).take(max).collect()
|
||||
} else {
|
||||
let mut stack = self.stack.iter();
|
||||
let first = stack.next().unwrap();
|
||||
let mut curr_log_entry = file.iter_from(first.selected).next()?;
|
||||
for elem in stack {
|
||||
curr_log_entry = curr_log_entry.get(elem.selected)?;
|
||||
}
|
||||
|
||||
match curr_log_entry.as_ref() {
|
||||
LogEntry::Single { .. } => return None,
|
||||
LogEntry::Sub { sub_entries, .. } => {
|
||||
FilterAdapter::new(file, &sub_entries[self.curr.first_item..])
|
||||
.take(max)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Some((items, self.curr.first_item, self.curr.selected))
|
||||
}
|
||||
|
||||
pub fn selected(&self, file: &mut LogfileReader) -> Option<Rc<LogEntry>> {
|
||||
self.items(file, self.curr.selected - self.curr.first_item + 1)?
|
||||
.0
|
||||
.get(self.curr.selected - self.curr.first_item)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn update_footer_select(&mut self) {
|
||||
self.footer_list.select(Some(0));
|
||||
}
|
||||
|
||||
pub fn prev(&mut self) {
|
||||
if self.footer_selected {
|
||||
self.footer_list.previous();
|
||||
} else {
|
||||
self.curr.selected = self.curr.selected.saturating_sub(1);
|
||||
self.curr.first_item = self.curr.first_item.min(self.curr.selected);
|
||||
self.update_footer_select();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next(&mut self) {
|
||||
if self.footer_selected {
|
||||
self.footer_list.next();
|
||||
} else {
|
||||
self.curr.selected += 1;
|
||||
self.update_footer_select();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn page_down(&mut self) {
|
||||
self.curr.selected += self.last_height;
|
||||
self.footer_selected = false;
|
||||
self.update_footer_select();
|
||||
}
|
||||
|
||||
pub fn page_up(&mut self) {
|
||||
self.curr.selected = self.curr.selected.saturating_sub(self.last_height);
|
||||
self.curr.first_item = self.curr.first_item.min(self.curr.selected);
|
||||
self.footer_selected = false;
|
||||
self.update_footer_select();
|
||||
}
|
||||
|
||||
pub fn home(&mut self) {
|
||||
if self.footer_selected {
|
||||
self.footer_list.select(Some(0));
|
||||
} else {
|
||||
self.curr.selected = 0;
|
||||
self.curr.first_item = 0;
|
||||
self.update_footer_select();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path(&self) -> Vec<usize> {
|
||||
self.stack.iter().map(|i| i.selected).collect()
|
||||
}
|
||||
|
||||
pub fn back(&mut self) {
|
||||
self.cache.insert(self.path(), self.curr);
|
||||
self.curr = self.stack.pop().unwrap_or_default();
|
||||
self.footer_selected = false;
|
||||
self.update_footer_select();
|
||||
}
|
||||
|
||||
pub fn switch_focus(&mut self) {
|
||||
self.footer_selected = !self.footer_selected;
|
||||
}
|
||||
|
||||
pub fn enter(&mut self, file: &mut LogfileReader) {
|
||||
if !self.footer_selected {
|
||||
let Some(s) = self.selected(file) else {
|
||||
return;
|
||||
};
|
||||
if let LogEntry::Single { .. } = s.as_ref() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.stack
|
||||
.push(mem::replace(&mut self.curr, LogView::default()));
|
||||
if let Some(cached_view) = self.cache.get(&self.path()) {
|
||||
self.curr = *cached_view;
|
||||
}
|
||||
self.update_footer_select();
|
||||
}
|
||||
}
|
||||
}
|
||||
594
src/tui/mod.rs
Normal file
594
src/tui/mod.rs
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
use ratatui_themes::{Theme, ThemeName};
|
||||
use std::{
|
||||
fs::{self, DirEntry},
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
process::exit,
|
||||
};
|
||||
use tui_widget_list::{ListBuilder, ListView};
|
||||
|
||||
use crate::tui::{
|
||||
filter::{FilterKind, WipMatcher},
|
||||
log_viewer::LogViewer,
|
||||
};
|
||||
use crate::tui::{
|
||||
filter::{FilterSelection, WipFilter},
|
||||
reader::LogfileReader,
|
||||
};
|
||||
use ratatui::{
|
||||
DefaultTerminal,
|
||||
buffer::Buffer,
|
||||
crossterm::event::{self, Event, KeyCode, KeyModifiers},
|
||||
layout::{Constraint, HorizontalAlignment, Layout, Rect},
|
||||
style::Style,
|
||||
widgets::{
|
||||
Block, Clear, List, ListItem, ListState, Padding, Paragraph, StatefulWidget, Widget, Wrap,
|
||||
},
|
||||
};
|
||||
|
||||
pub mod filter;
|
||||
pub mod log_viewer;
|
||||
pub mod model;
|
||||
pub mod reader;
|
||||
|
||||
pub fn run(logs_dir: PathBuf) {
|
||||
let terminal = ratatui::init();
|
||||
let theme = Theme::new(ThemeName::OneDarkPro);
|
||||
let app_result = App::new(logs_dir, theme).run(terminal);
|
||||
ratatui::restore();
|
||||
|
||||
if let Err(e) = app_result {
|
||||
eprintln!("error in tui: {e:?}");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
enum Tab {
|
||||
FileChooser {
|
||||
files: Vec<DirEntry>,
|
||||
state: ListState,
|
||||
last_height: usize,
|
||||
},
|
||||
LogViewer(LogViewer),
|
||||
CreateFilter {
|
||||
filter: WipFilter,
|
||||
},
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
fn name(&self, path: Option<&Path>) -> String {
|
||||
match (self, path) {
|
||||
(Tab::Empty, _) => "dummy".to_string(),
|
||||
(Tab::FileChooser { .. }, _) => "choose a file".to_string(),
|
||||
(Tab::LogViewer(_), Some(path)) => format!("logs of {}", path.display()),
|
||||
(Tab::LogViewer(_), None) => "logs".to_string(),
|
||||
(Tab::CreateFilter { .. }, _) => "create filter".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize_filter(
|
||||
lv: &mut LogViewer,
|
||||
file: &mut LogfileReader,
|
||||
kind: Option<FilterKind>,
|
||||
) -> WipFilter {
|
||||
let matcher = if lv.footer_selected {
|
||||
let footer_fields = lv.footer_fields(file);
|
||||
let (key, value) = footer_fields
|
||||
.get(lv.footer_list.selected.unwrap_or(0))
|
||||
.map_or((None, None), |(k, v)| (Some(k), Some(v)));
|
||||
Some(WipMatcher::Field {
|
||||
name: key.cloned(),
|
||||
value: value.cloned(),
|
||||
})
|
||||
} else {
|
||||
Some(WipMatcher::Specific {
|
||||
path: Some(lv.path().clone()),
|
||||
})
|
||||
};
|
||||
|
||||
WipFilter {
|
||||
matcher,
|
||||
kind,
|
||||
selection: filter::FilterSelection::Kind,
|
||||
}
|
||||
}
|
||||
|
||||
struct App {
|
||||
tabs: Vec<Tab>,
|
||||
logs_dir: PathBuf,
|
||||
current_file: Option<LogfileReader>,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new(logs_dir: PathBuf, theme: Theme) -> Self {
|
||||
let mut res = Self {
|
||||
tabs: Vec::new(),
|
||||
current_file: None,
|
||||
logs_dir,
|
||||
theme,
|
||||
};
|
||||
res.replace_tab(res.choose_file());
|
||||
res
|
||||
}
|
||||
|
||||
fn current_file_path(&self) -> Option<PathBuf> {
|
||||
self.current_file.as_ref().map(|i| i.path.to_path_buf())
|
||||
}
|
||||
|
||||
fn replace_tab(&mut self, tab: Tab) {
|
||||
if let Some(last) = self.tabs.last_mut() {
|
||||
*last = tab;
|
||||
} else {
|
||||
self.tabs = vec![tab];
|
||||
}
|
||||
}
|
||||
|
||||
fn push_tab(&mut self, tab: Tab) {
|
||||
self.tabs.push(tab);
|
||||
}
|
||||
|
||||
fn pop_tab(&mut self) {
|
||||
let _ = self.tabs.pop();
|
||||
}
|
||||
|
||||
fn choose_file(&self) -> Tab {
|
||||
fn init(logs_dir: &Path) -> io::Result<Vec<DirEntry>> {
|
||||
let mut files = Vec::new();
|
||||
|
||||
for file in fs::read_dir(logs_dir)? {
|
||||
let file = file?;
|
||||
files.push(file);
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
match init(&self.logs_dir) {
|
||||
Ok(files) => Tab::FileChooser {
|
||||
files,
|
||||
state: ListState::default(),
|
||||
last_height: 0,
|
||||
},
|
||||
Err(_) => Tab::Empty,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_tab(&mut self) -> &mut Tab {
|
||||
if self.tabs.is_empty() {
|
||||
self.tabs.push(Tab::Empty);
|
||||
}
|
||||
|
||||
self.tabs.last_mut().unwrap()
|
||||
}
|
||||
|
||||
fn run(mut self, mut terminal: DefaultTerminal) -> io::Result<()> {
|
||||
loop {
|
||||
terminal.draw(|frame| frame.render_widget(&mut self, frame.area()))?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
// to initialize, but we then do get manually for borrow reasons
|
||||
self.current_tab();
|
||||
let num_tabs = self.tabs.len();
|
||||
match (key.code, self.tabs.last_mut().unwrap()) {
|
||||
(KeyCode::Char('q'), _) => return Ok(()),
|
||||
(KeyCode::Char('c'), _) if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
return Ok(());
|
||||
}
|
||||
(KeyCode::Esc, _) if num_tabs > 1 => {
|
||||
self.pop_tab();
|
||||
}
|
||||
(KeyCode::Char('j') | KeyCode::Down, tab) => match tab {
|
||||
Tab::FileChooser { state, .. } => state.select_next(),
|
||||
Tab::LogViewer(lv) => lv.next(),
|
||||
Tab::Empty => {}
|
||||
Tab::CreateFilter { filter } => {
|
||||
filter.selection.next();
|
||||
}
|
||||
},
|
||||
(KeyCode::Char('k') | KeyCode::Up, tab) => match tab {
|
||||
Tab::FileChooser { state, .. } => state.select_previous(),
|
||||
Tab::LogViewer(lv) => {
|
||||
lv.prev();
|
||||
}
|
||||
Tab::Empty => {}
|
||||
Tab::CreateFilter { filter } => {
|
||||
filter.selection.prev();
|
||||
}
|
||||
},
|
||||
(KeyCode::PageDown, tab) => match tab {
|
||||
Tab::FileChooser {
|
||||
state, last_height, ..
|
||||
} => state.scroll_down_by(*last_height as u16),
|
||||
Tab::LogViewer(lv) => {
|
||||
lv.page_down();
|
||||
}
|
||||
Tab::Empty => {}
|
||||
Tab::CreateFilter { .. } => {}
|
||||
},
|
||||
(KeyCode::PageUp, tab) => match tab {
|
||||
Tab::FileChooser {
|
||||
state, last_height, ..
|
||||
} => state.scroll_up_by(*last_height as u16),
|
||||
Tab::LogViewer(lv) => {
|
||||
lv.page_up();
|
||||
}
|
||||
Tab::Empty => {}
|
||||
Tab::CreateFilter { .. } => {}
|
||||
},
|
||||
(KeyCode::Char('G') | KeyCode::Home, tab) => match tab {
|
||||
Tab::FileChooser { state, .. } => state.select_first(),
|
||||
Tab::LogViewer(lv) => {
|
||||
lv.home();
|
||||
}
|
||||
Tab::Empty => {}
|
||||
Tab::CreateFilter { .. } => {}
|
||||
},
|
||||
(KeyCode::Char('g') | KeyCode::End, tab) => match tab {
|
||||
Tab::FileChooser { state, .. } => state.select_last(),
|
||||
Tab::LogViewer(lv) => {}
|
||||
Tab::Empty => {}
|
||||
Tab::CreateFilter { .. } => {}
|
||||
},
|
||||
(KeyCode::Backspace | KeyCode::Left | KeyCode::Esc, Tab::LogViewer(lv)) => {
|
||||
lv.back();
|
||||
}
|
||||
(KeyCode::Backspace, Tab::CreateFilter { filter }) => {
|
||||
filter.clear();
|
||||
}
|
||||
(KeyCode::Right, Tab::CreateFilter { filter }) => {
|
||||
filter.right();
|
||||
}
|
||||
(KeyCode::Left, Tab::CreateFilter { filter }) => {
|
||||
filter.left();
|
||||
}
|
||||
(KeyCode::Right, Tab::LogViewer(lv)) => {
|
||||
if let Some(file) = &mut self.current_file {
|
||||
lv.enter(file)
|
||||
}
|
||||
}
|
||||
(KeyCode::Tab, Tab::LogViewer(lv)) => {
|
||||
lv.switch_focus();
|
||||
}
|
||||
(KeyCode::Char('r'), Tab::LogViewer(lv)) => {
|
||||
if let Some(file) = &mut self.current_file {
|
||||
let filter = initialize_filter(lv, file, Some(FilterKind::Remove));
|
||||
self.push_tab(Tab::CreateFilter { filter });
|
||||
}
|
||||
}
|
||||
(KeyCode::Char('i'), Tab::LogViewer(lv)) => {
|
||||
if let Some(file) = &mut self.current_file {
|
||||
let filter = initialize_filter(lv, file, Some(FilterKind::Inline));
|
||||
self.push_tab(Tab::CreateFilter { filter });
|
||||
}
|
||||
}
|
||||
(KeyCode::Enter, tab) => match tab {
|
||||
Tab::FileChooser { files, state, .. } => {
|
||||
if let Some(selected) = state.selected()
|
||||
&& let Some(selected) = files.get(selected)
|
||||
{
|
||||
match LogfileReader::new(&selected.path()) {
|
||||
Ok(i) => {
|
||||
self.current_file = Some(i);
|
||||
self.replace_tab(Tab::LogViewer(LogViewer::new()));
|
||||
}
|
||||
Err(e) => {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Tab::LogViewer(lv) => {
|
||||
if let Some(file) = &mut self.current_file {
|
||||
if lv.footer_selected {
|
||||
let filter = initialize_filter(lv, file, None);
|
||||
self.push_tab(Tab::CreateFilter { filter });
|
||||
} else {
|
||||
lv.enter(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
Tab::Empty => {}
|
||||
Tab::CreateFilter { filter } => {
|
||||
if let FilterSelection::Confirm = filter.selection
|
||||
&& let Some(file) = &mut self.current_file
|
||||
{
|
||||
if let Some(filter) = filter.validate() {
|
||||
file.add_filter(filter);
|
||||
self.pop_tab();
|
||||
|
||||
if let Tab::LogViewer(lv) = self.current_tab() {
|
||||
lv.footer_selected = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filter.selection.next();
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for &mut App {
|
||||
fn render(self, area: Rect, buf: &mut Buffer)
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let palette = self.theme.palette();
|
||||
let default = Style::new().fg(palette.fg).bg(palette.bg);
|
||||
let highlighted = Style::new().fg(palette.accent).bg(palette.selection);
|
||||
let border = Style::new().fg(palette.fg).bg(palette.bg);
|
||||
let border_selected = Style::new().fg(palette.secondary).bg(palette.bg);
|
||||
|
||||
let [header_area, main_area, footer_area] = Layout::vertical([
|
||||
Constraint::Length(2),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Ratio(1, 4),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
let [_, popup_area, _] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Min(40),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(area);
|
||||
let [_, popup_area, _] = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Min(40),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(popup_area);
|
||||
|
||||
let (footer_focused, header_focused) = match self.current_tab() {
|
||||
Tab::FileChooser { .. } => (false, true),
|
||||
Tab::LogViewer(lv) => (lv.footer_selected, !lv.footer_selected),
|
||||
Tab::Empty => (false, false),
|
||||
Tab::CreateFilter { .. } => (false, false),
|
||||
};
|
||||
|
||||
let main_area = {
|
||||
let block = Block::bordered()
|
||||
.style(default)
|
||||
.border_style(if header_focused {
|
||||
border_selected
|
||||
} else {
|
||||
border
|
||||
});
|
||||
let inner = block.inner(main_area);
|
||||
block.render(main_area, buf);
|
||||
inner
|
||||
};
|
||||
|
||||
let footer_area = {
|
||||
let block = Block::bordered()
|
||||
.style(default)
|
||||
.border_style(if footer_focused {
|
||||
border_selected
|
||||
} else {
|
||||
border
|
||||
});
|
||||
let inner = block.inner(footer_area);
|
||||
block.render(footer_area, buf);
|
||||
inner
|
||||
};
|
||||
|
||||
let [left, middle, right] = Layout::horizontal([
|
||||
Constraint::Ratio(1, 3),
|
||||
Constraint::Ratio(1, 3),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(header_area);
|
||||
|
||||
let current_file_path = self.current_file_path();
|
||||
let breadcrumbs = self.tabs[..self.tabs.len() - 1]
|
||||
.iter()
|
||||
.map(|i| i.name(current_file_path.as_deref()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("►");
|
||||
Paragraph::new(breadcrumbs)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(left, buf);
|
||||
|
||||
Paragraph::new(self.current_tab().name(current_file_path.as_deref()))
|
||||
.alignment(HorizontalAlignment::Center)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(middle, buf);
|
||||
|
||||
for tab in &mut self.tabs {
|
||||
match tab {
|
||||
Tab::FileChooser {
|
||||
files,
|
||||
state,
|
||||
last_height,
|
||||
} => {
|
||||
let list = List::new(files.iter().map(|file| {
|
||||
ListItem::new(file.file_name().to_string_lossy().into_owned())
|
||||
}))
|
||||
.style(default)
|
||||
.highlight_style(highlighted);
|
||||
|
||||
*last_height = main_area.height as usize;
|
||||
|
||||
StatefulWidget::render(list, main_area, buf, state);
|
||||
}
|
||||
Tab::LogViewer(lv) => {
|
||||
let Some(file) = &mut self.current_file else {
|
||||
continue;
|
||||
};
|
||||
|
||||
lv.update_num_items(main_area.height as usize);
|
||||
|
||||
let (items, start, selected) = lv
|
||||
.items(file, main_area.height as usize)
|
||||
.unwrap_or_else(|| (Vec::new(), 0, 0));
|
||||
|
||||
let list = List::new(items.into_iter().enumerate().map(|(idx, i)| {
|
||||
let line = i.line_text(false);
|
||||
|
||||
let mut list_item = ListItem::new(line);
|
||||
|
||||
if idx + start == selected {
|
||||
list_item = list_item.style(highlighted);
|
||||
}
|
||||
|
||||
list_item
|
||||
}));
|
||||
Widget::render(list, main_area, buf);
|
||||
|
||||
let items = lv.footer_fields(file);
|
||||
let width = 20;
|
||||
let builder = ListBuilder::new(|cx| {
|
||||
let Some((k, v)) = &items.get(cx.index) else {
|
||||
return (Paragraph::new(""), 1);
|
||||
};
|
||||
let contents = serde_json::to_string_pretty(&v)
|
||||
.unwrap_or(String::new())
|
||||
.replace("\n", "\n{:width$}");
|
||||
|
||||
let mut res = Paragraph::new(format!("{k:width$} {contents}"))
|
||||
.wrap(Wrap { trim: false });
|
||||
|
||||
if cx.is_selected {
|
||||
res = res.style(highlighted);
|
||||
}
|
||||
|
||||
let height = res.line_count(footer_area.width) as u16;
|
||||
(res, height)
|
||||
});
|
||||
|
||||
let list = ListView::new(builder, items.len());
|
||||
StatefulWidget::render(list, footer_area, buf, &mut lv.footer_list);
|
||||
}
|
||||
Tab::Empty => {}
|
||||
Tab::CreateFilter { filter } => {
|
||||
Clear.render(popup_area, buf);
|
||||
let popup_area = {
|
||||
let block = Block::bordered()
|
||||
.title_top("create filter")
|
||||
.style(default)
|
||||
.padding(Padding::symmetric(3, 1))
|
||||
.border_style(border_selected);
|
||||
let inner = block.inner(popup_area);
|
||||
block.render(popup_area, buf);
|
||||
inner
|
||||
};
|
||||
|
||||
let [kind, matcher_kind, matcher_area, confirm] = Layout::vertical([
|
||||
Constraint::Length(5),
|
||||
Constraint::Length(5),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(popup_area);
|
||||
|
||||
let text = match &filter.kind {
|
||||
None => "<unset>",
|
||||
Some(FilterKind::Inline) => "inline items",
|
||||
Some(FilterKind::Remove) => "remove item and sub-items",
|
||||
};
|
||||
Paragraph::new(format!("⏴ {text} ⏵"))
|
||||
.centered()
|
||||
.style(
|
||||
if matches!(filter.selection, filter::FilterSelection::Kind) {
|
||||
highlighted
|
||||
} else {
|
||||
default
|
||||
},
|
||||
)
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title_top("transformation")
|
||||
.padding(Padding::uniform(1)),
|
||||
)
|
||||
.render(kind, buf);
|
||||
|
||||
let text = match filter.matcher.as_ref() {
|
||||
None => "<unset>",
|
||||
Some(WipMatcher::Field { .. }) => "all logs where field matches",
|
||||
Some(WipMatcher::Specific { .. }) => "this specific log",
|
||||
};
|
||||
Paragraph::new(format!("⏴ {text} ⏵"))
|
||||
.centered()
|
||||
.style(
|
||||
if matches!(filter.selection, filter::FilterSelection::MatcherKind) {
|
||||
highlighted
|
||||
} else {
|
||||
default
|
||||
},
|
||||
)
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title_top("matcher")
|
||||
.padding(Padding::uniform(1)),
|
||||
)
|
||||
.render(matcher_kind, buf);
|
||||
|
||||
match &filter.matcher {
|
||||
Some(WipMatcher::Field { name, value }) => {
|
||||
let [field_area, value_area, _] = Layout::vertical([
|
||||
Constraint::Length(5),
|
||||
Constraint::Length(5),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(matcher_area);
|
||||
|
||||
Paragraph::new(format!("{}", name.clone().unwrap_or_default()))
|
||||
.centered()
|
||||
.style(
|
||||
if matches!(filter.selection, filter::FilterSelection::Matcher)
|
||||
{
|
||||
highlighted
|
||||
} else {
|
||||
default
|
||||
},
|
||||
)
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title_top("field name")
|
||||
.padding(Padding::uniform(1)),
|
||||
)
|
||||
.render(field_area, buf);
|
||||
|
||||
Paragraph::new(format!("{}", value.clone().unwrap_or_default()))
|
||||
.centered()
|
||||
.style(
|
||||
if matches!(filter.selection, filter::FilterSelection::Matcher)
|
||||
{
|
||||
highlighted
|
||||
} else {
|
||||
default
|
||||
},
|
||||
)
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title_top("value")
|
||||
.padding(Padding::uniform(1)),
|
||||
)
|
||||
.render(value_area, buf);
|
||||
}
|
||||
Some(WipMatcher::Specific { .. }) => {}
|
||||
None => {}
|
||||
}
|
||||
|
||||
Paragraph::new("confirm")
|
||||
.centered()
|
||||
.style(
|
||||
if matches!(filter.selection, filter::FilterSelection::Confirm) {
|
||||
highlighted
|
||||
} else {
|
||||
default
|
||||
},
|
||||
)
|
||||
.render(confirm, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
134
src/tui/model.rs
Normal file
134
src/tui/model.rs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
use std::{collections::BTreeMap, rc::Rc, sync::OnceLock};
|
||||
|
||||
use jiff::Timestamp;
|
||||
use ratatui::text::Line;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub enum Level {
|
||||
#[serde(rename = "TRACE")]
|
||||
Trace,
|
||||
#[serde(rename = "DEBUG")]
|
||||
Debug,
|
||||
#[serde(rename = "INFO")]
|
||||
Info,
|
||||
#[serde(rename = "Warn")]
|
||||
Warn,
|
||||
#[serde(rename = "Error")]
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LogEntry {
|
||||
Single {
|
||||
raw: RawLogEntry,
|
||||
},
|
||||
Sub {
|
||||
enter: RawLogEntry,
|
||||
sub_entries: Vec<Rc<LogEntry>>,
|
||||
exit: RawLogEntry,
|
||||
|
||||
count_sub: OnceLock<usize>,
|
||||
},
|
||||
}
|
||||
|
||||
impl LogEntry {
|
||||
pub fn get(&self, index: usize) -> Option<Rc<LogEntry>> {
|
||||
match self {
|
||||
LogEntry::Single { .. } => None,
|
||||
LogEntry::Sub { sub_entries, .. } => sub_entries.get(index).cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_fields(&self) -> LogFields {
|
||||
match self {
|
||||
LogEntry::Single { raw } => raw.all_fields(),
|
||||
LogEntry::Sub { enter, exit, .. } => enter.all_fields().merge(&exit.all_fields()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn count(&self) -> usize {
|
||||
match self {
|
||||
LogEntry::Single { .. } => 1,
|
||||
LogEntry::Sub {
|
||||
sub_entries,
|
||||
count_sub,
|
||||
..
|
||||
} => {
|
||||
*count_sub.get_or_init(|| sub_entries.iter().map(|i| i.count()).sum::<usize>() + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn line_text(&self, accessed: bool) -> Line<'static> {
|
||||
const NO_MESSAGE: &str = "<no message>";
|
||||
match self {
|
||||
LogEntry::Single { raw } => {
|
||||
format!(" ┃{}", raw.fields.message().unwrap_or(NO_MESSAGE)).into()
|
||||
}
|
||||
LogEntry::Sub {
|
||||
enter, sub_entries, ..
|
||||
} => {
|
||||
if let Some(val) = enter.all_fields().fields.get("name")
|
||||
&& let Some(s) = val.as_str()
|
||||
{
|
||||
Line::from(format!(
|
||||
"{:3}⭣{:3}⇊ ┃↪ {s}",
|
||||
sub_entries.len(),
|
||||
self.count().wrapping_sub(1)
|
||||
))
|
||||
} else {
|
||||
format!(
|
||||
" ┃{}",
|
||||
enter.fields.message().unwrap_or(NO_MESSAGE)
|
||||
)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct LogFields {
|
||||
#[serde(flatten)]
|
||||
pub fields: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl LogFields {
|
||||
pub fn message(&self) -> Option<&str> {
|
||||
self.fields.get("message").and_then(|i| i.as_str())
|
||||
}
|
||||
|
||||
pub fn merge(&self, other: &Self) -> Self {
|
||||
Self {
|
||||
fields: self
|
||||
.fields
|
||||
.iter()
|
||||
.chain(other.fields.iter())
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct RawLogEntry {
|
||||
pub timestamp: Timestamp,
|
||||
pub level: Level,
|
||||
pub filename: String,
|
||||
pub line_number: usize,
|
||||
pub fields: LogFields,
|
||||
#[serde(default)]
|
||||
pub spans: Vec<LogFields>,
|
||||
}
|
||||
|
||||
impl RawLogEntry {
|
||||
pub fn all_fields(&self) -> LogFields {
|
||||
let mut res = self.fields.clone();
|
||||
for i in &self.spans {
|
||||
res = res.merge(i);
|
||||
}
|
||||
res
|
||||
}
|
||||
}
|
||||
163
src/tui/reader.rs
Normal file
163
src/tui/reader.rs
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
use std::{
|
||||
fs::File,
|
||||
io::{self, BufRead, BufReader},
|
||||
mem,
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
sync::OnceLock,
|
||||
};
|
||||
|
||||
use crate::tui::{
|
||||
filter::{Filter, FilterKind},
|
||||
model::{LogEntry, RawLogEntry},
|
||||
};
|
||||
|
||||
pub struct LogfileReader {
|
||||
pub path: PathBuf,
|
||||
file: BufReader<File>,
|
||||
|
||||
entries: Vec<Rc<LogEntry>>,
|
||||
filters: Vec<Rc<Filter>>,
|
||||
}
|
||||
|
||||
impl LogfileReader {
|
||||
pub fn new(p: &Path) -> io::Result<Self> {
|
||||
Ok(Self {
|
||||
file: BufReader::new(File::open(p)?),
|
||||
path: p.to_path_buf(),
|
||||
entries: Vec::new(),
|
||||
filters: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add_filter(&mut self, filter: Filter) {
|
||||
self.filters.push(Rc::new(filter));
|
||||
}
|
||||
|
||||
fn next_line(&mut self) -> Option<String> {
|
||||
let mut res = String::new();
|
||||
match self.file.read_line(&mut res) {
|
||||
Err(e) => {
|
||||
eprintln!("error: {e:?}");
|
||||
None
|
||||
}
|
||||
Ok(0) => None,
|
||||
Ok(_) => Some(res),
|
||||
}
|
||||
}
|
||||
|
||||
fn next_raw_entry(&mut self) -> Option<RawLogEntry> {
|
||||
let line = self.next_line()?;
|
||||
match serde_json::from_str(&line) {
|
||||
Ok(i) => Some(i),
|
||||
Err(e) => {
|
||||
eprintln!("deserializing: {e:?} in {line}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn next_entry(&mut self) -> Option<Rc<LogEntry>> {
|
||||
let mut stack = Vec::<(RawLogEntry, Vec<Rc<LogEntry>>)>::new();
|
||||
let mut curr = Vec::<Rc<LogEntry>>::new();
|
||||
|
||||
loop {
|
||||
let entry = self.next_raw_entry()?;
|
||||
|
||||
let new_entry = Rc::new(match entry.fields.message() {
|
||||
Some("enter") => {
|
||||
stack.push((entry, mem::take(&mut curr)));
|
||||
continue;
|
||||
}
|
||||
Some("exit") => {
|
||||
// TODO: does it match?
|
||||
let Some((enter, prev)) = stack.pop() else {
|
||||
panic!("exit before entry");
|
||||
};
|
||||
let sub_entries = mem::replace(&mut curr, prev);
|
||||
LogEntry::Sub {
|
||||
enter: enter,
|
||||
sub_entries,
|
||||
exit: entry,
|
||||
count_sub: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
_ => LogEntry::Single { raw: entry },
|
||||
});
|
||||
|
||||
if stack.is_empty() {
|
||||
return Some(new_entry);
|
||||
} else {
|
||||
curr.push(new_entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn iter_from(&mut self, start: usize) -> FilterAdapter<EntryIterator<'_>> {
|
||||
FilterAdapter {
|
||||
filters: self.filters.clone(),
|
||||
inner: EntryIterator {
|
||||
curr: start,
|
||||
reader: self,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn add_next_entry(&mut self) -> Option<()> {
|
||||
let entry = self.next_entry()?;
|
||||
self.entries.push(entry);
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EntryIterator<'a> {
|
||||
curr: usize,
|
||||
reader: &'a mut LogfileReader,
|
||||
}
|
||||
|
||||
impl<'a> Iterator for EntryIterator<'a> {
|
||||
type Item = Rc<LogEntry>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
while self.reader.entries.len() <= self.curr {
|
||||
self.reader.add_next_entry()?;
|
||||
}
|
||||
|
||||
let res = Rc::clone(&self.reader.entries[self.curr]);
|
||||
self.curr += 1;
|
||||
Some(res)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FilterAdapter<I> {
|
||||
filters: Vec<Rc<Filter>>,
|
||||
inner: I,
|
||||
}
|
||||
|
||||
impl<I: IntoIterator> FilterAdapter<I> {
|
||||
pub fn new(file: &LogfileReader, inner: I) -> FilterAdapter<I::IntoIter> {
|
||||
Self {
|
||||
filters: file.filters.clone(),
|
||||
inner: inner.into_iter(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<I: Iterator> Iterator for FilterAdapter<I> {
|
||||
type Item = I::Item;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
'next_entry: loop {
|
||||
let res = self.inner.next()?;
|
||||
|
||||
for filter in &self.reader.filters {
|
||||
if let FilterKind::Remove = filter.kind
|
||||
&& filter.matcher.matches(&res)
|
||||
{
|
||||
continue 'next_entry;
|
||||
}
|
||||
}
|
||||
break Some(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue