mirror of
https://codeberg.org/icewind/logsmash.git
synced 2026-06-03 18:14:11 +02:00
some refactoring
This commit is contained in:
parent
14734ad089
commit
da02bb0329
12 changed files with 237 additions and 111 deletions
|
|
@ -6,6 +6,15 @@ use std::borrow::Cow;
|
||||||
pub use types::*;
|
pub use types::*;
|
||||||
use version_compare::{compare, Cmp};
|
use version_compare::{compare, Cmp};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Copy, Hash, Default)]
|
||||||
|
pub struct LogStatementIndex(usize);
|
||||||
|
|
||||||
|
impl From<usize> for LogStatementIndex {
|
||||||
|
fn from(value: usize) -> Self {
|
||||||
|
LogStatementIndex(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct StatementList {
|
pub struct StatementList {
|
||||||
statements: Vec<(&'static str, &'static [LoggingStatement])>,
|
statements: Vec<(&'static str, &'static [LoggingStatement])>,
|
||||||
}
|
}
|
||||||
|
|
@ -15,21 +24,25 @@ impl StatementList {
|
||||||
StatementList { statements }
|
StatementList { statements }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn iter(&self) -> impl Iterator<Item = &'static LoggingStatement> + Send + '_ {
|
pub fn iter(
|
||||||
|
&self,
|
||||||
|
) -> impl Iterator<Item = (LogStatementIndex, &'static LoggingStatement)> + Send + '_ {
|
||||||
self.statements
|
self.statements
|
||||||
.iter()
|
.iter()
|
||||||
.copied()
|
.copied()
|
||||||
.flat_map(|(_, list)| list.iter())
|
.flat_map(|(_, list)| list.iter())
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, statement)| (LogStatementIndex(index), statement))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get(&self, mut index: usize) -> Option<LoggingStatementWithPathPrefix> {
|
pub fn get(&self, mut index: LogStatementIndex) -> Option<LoggingStatementWithPathPrefix> {
|
||||||
for (prefix, list) in &self.statements {
|
for (prefix, list) in &self.statements {
|
||||||
if index < list.len() {
|
if index.0 < list.len() {
|
||||||
return list
|
return list
|
||||||
.get(index)
|
.get(index.0)
|
||||||
.map(|statement| statement.with_path_prefix(prefix));
|
.map(|statement| statement.with_path_prefix(prefix));
|
||||||
}
|
}
|
||||||
index -= list.len()
|
index.0 -= list.len()
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
|
||||||
65
src/app.rs
65
src/app.rs
|
|
@ -1,21 +1,21 @@
|
||||||
use crate::logfile::{LogFile, LogIndex, LogLine};
|
use crate::logfile::{LogFile, LogLine, LogLineNumber};
|
||||||
|
use crate::logs::{LogIndex, ParsedLogs};
|
||||||
use crate::matcher::MatchResult;
|
use crate::matcher::MatchResult;
|
||||||
use crate::timegraph::TimeGraph;
|
use crate::timegraph::TimeGraph;
|
||||||
use logsmash_data::{LoggingStatementWithPathPrefix, StatementList};
|
use logsmash_data::{LoggingStatementWithPathPrefix, StatementList};
|
||||||
use regex::{escape, Regex, RegexBuilder};
|
use regex::{escape, Regex, RegexBuilder};
|
||||||
|
use serde_json::Error as JsonError;
|
||||||
use std::cell::OnceCell;
|
use std::cell::OnceCell;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
|
|
||||||
pub struct App<'a> {
|
pub struct App<'a> {
|
||||||
pub lines: Vec<LogLine<'a>>,
|
pub lines: ParsedLogs<'a>,
|
||||||
pub log_statements: StatementList,
|
pub log_statements: StatementList,
|
||||||
pub matches: Vec<LogMatch>,
|
pub matches: Vec<LogMatch>,
|
||||||
pub error_count: usize,
|
|
||||||
pub all: LogMatch,
|
pub all: LogMatch,
|
||||||
pub unmatched: LogMatch,
|
pub unmatched: LogMatch,
|
||||||
pub log_file: &'a LogFile,
|
pub log_file: &'a LogFile,
|
||||||
pub error_lines: Vec<(String, serde_json::Error)>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> App<'a> {
|
impl<'a> App<'a> {
|
||||||
|
|
@ -24,19 +24,30 @@ impl<'a> App<'a> {
|
||||||
self.matches.len() + 1 + unmatched_line_count
|
self.matches.len() + 1 + unmatched_line_count
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_line(&self, index: LogIndex) -> Option<&'a str> {
|
pub fn get_source_line(&self, index: LogLineNumber) -> Option<&'a str> {
|
||||||
self.log_file.nth(index)
|
self.log_file.nth(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_line(&self, index: LogIndex) -> &LogLine<'a> {
|
||||||
|
&self.lines[index]
|
||||||
|
}
|
||||||
|
|
||||||
pub fn line_indices_by_request<'b>(
|
pub fn line_indices_by_request<'b>(
|
||||||
&'b self,
|
&'b self,
|
||||||
request_id: &'b str,
|
request_id: &'b str,
|
||||||
) -> impl Iterator<Item = usize> + 'b {
|
) -> impl Iterator<Item = LogIndex> + 'b {
|
||||||
self.lines
|
self.lines
|
||||||
.iter()
|
.find_indices(move |line| line.request_id == request_id)
|
||||||
.enumerate()
|
}
|
||||||
.filter(move |(_, line)| line.request_id == request_id)
|
|
||||||
.map(|(i, _)| i)
|
pub fn error_lines(&self) -> impl Iterator<Item = (&'a str, &JsonError)> {
|
||||||
|
self.lines.errors().iter().map(|(line_number, error)| {
|
||||||
|
(self.log_file.nth(*line_number).unwrap_or_default(), error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error_count(&self) -> usize {
|
||||||
|
self.lines.errors().len()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,15 +56,15 @@ pub struct LogMatch {
|
||||||
pub count: usize,
|
pub count: usize,
|
||||||
pub histogram: OnceCell<TimeGraph>,
|
pub histogram: OnceCell<TimeGraph>,
|
||||||
pub sparkline: OnceCell<String>,
|
pub sparkline: OnceCell<String>,
|
||||||
pub all: GroupedLines,
|
pub all: LineSet,
|
||||||
pub grouped: Vec<GroupedLines>,
|
pub grouped: Vec<LineSet>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LogMatch {
|
impl LogMatch {
|
||||||
pub fn new(result: Option<MatchResult>, lines: Vec<usize>, all_lines: &[LogLine]) -> Self {
|
pub fn new(result: Option<MatchResult>, lines: Vec<LogIndex>, all_lines: &ParsedLogs) -> Self {
|
||||||
let count = lines.len();
|
let count = lines.len();
|
||||||
let grouped = group_lines(all_lines, lines.iter().copied());
|
let grouped = group_lines(all_lines, lines.iter().copied());
|
||||||
let all = GroupedLines::new(lines);
|
let all = LineSet::new(lines);
|
||||||
|
|
||||||
LogMatch {
|
LogMatch {
|
||||||
result,
|
result,
|
||||||
|
|
@ -73,10 +84,10 @@ impl LogMatch {
|
||||||
|
|
||||||
pub fn histogram(&self, app: &App) -> &TimeGraph {
|
pub fn histogram(&self, app: &App) -> &TimeGraph {
|
||||||
self.histogram.get_or_init(|| {
|
self.histogram.get_or_init(|| {
|
||||||
let min_time = app.lines[0].time;
|
let min_time = app.lines.first().time;
|
||||||
let max_time = app.lines.last().unwrap().time;
|
let max_time = app.lines.last().time;
|
||||||
let mut histogram = TimeGraph::new(min_time, max_time);
|
let mut histogram = TimeGraph::new(min_time, max_time);
|
||||||
for line in self.all.lines.iter().map(|line| &app.lines[*line]) {
|
for line in self.all.lines.iter().map(|line| app.get_line(*line)) {
|
||||||
histogram.add(line.time);
|
histogram.add(line.time);
|
||||||
}
|
}
|
||||||
histogram
|
histogram
|
||||||
|
|
@ -125,28 +136,28 @@ impl LogMatch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn group_lines<I: Iterator<Item = usize>>(all_lines: &[LogLine], indices: I) -> Vec<GroupedLines> {
|
fn group_lines<I: Iterator<Item = LogIndex>>(all_lines: &ParsedLogs, indices: I) -> Vec<LineSet> {
|
||||||
let mut map: BTreeMap<u64, Vec<usize>> = BTreeMap::new();
|
let mut map: BTreeMap<u64, Vec<LogIndex>> = BTreeMap::new();
|
||||||
|
|
||||||
for (i, line) in indices.map(|i| (i, &all_lines[i])) {
|
for (i, line) in indices.map(|i| (i, &all_lines[i])) {
|
||||||
map.entry(line.identity()).or_default().push(i);
|
map.entry(line.identity()).or_default().push(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut list: Vec<_> = map.into_values().map(GroupedLines::new).collect();
|
let mut list: Vec<_> = map.into_values().map(LineSet::new).collect();
|
||||||
list.sort_by_key(|list| list.len());
|
list.sort_by_key(|list| list.len());
|
||||||
list.reverse();
|
list.reverse();
|
||||||
list
|
list
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct GroupedLines {
|
pub struct LineSet {
|
||||||
pub lines: Vec<usize>,
|
pub lines: Vec<LogIndex>,
|
||||||
pub histogram: OnceCell<TimeGraph>,
|
pub histogram: OnceCell<TimeGraph>,
|
||||||
pub sparkline: OnceCell<String>,
|
pub sparkline: OnceCell<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GroupedLines {
|
impl LineSet {
|
||||||
pub fn new(lines: Vec<usize>) -> Self {
|
pub fn new(lines: Vec<LogIndex>) -> Self {
|
||||||
GroupedLines {
|
LineSet {
|
||||||
lines,
|
lines,
|
||||||
histogram: OnceCell::new(),
|
histogram: OnceCell::new(),
|
||||||
sparkline: OnceCell::new(),
|
sparkline: OnceCell::new(),
|
||||||
|
|
@ -161,8 +172,8 @@ impl GroupedLines {
|
||||||
|
|
||||||
pub fn histogram(&self, app: &App) -> &TimeGraph {
|
pub fn histogram(&self, app: &App) -> &TimeGraph {
|
||||||
self.histogram.get_or_init(|| {
|
self.histogram.get_or_init(|| {
|
||||||
let min_time = app.lines[0].time;
|
let min_time = app.lines.first().time;
|
||||||
let max_time = app.lines.last().unwrap().time;
|
let max_time = app.lines.last().time;
|
||||||
let mut histogram = TimeGraph::new(min_time, max_time);
|
let mut histogram = TimeGraph::new(min_time, max_time);
|
||||||
for line in self.lines.iter().map(|line| &app.lines[*line]) {
|
for line in self.lines.iter().map(|line| &app.lines[*line]) {
|
||||||
histogram.add(line.time);
|
histogram.add(line.time);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use crate::app::Filter;
|
use crate::app::Filter;
|
||||||
use crate::logfile::LogIndex;
|
use crate::logfile::LogLineNumber;
|
||||||
|
use crate::logs::LogIndex;
|
||||||
use ahash::AHasher;
|
use ahash::AHasher;
|
||||||
use derive_more::{Display, From};
|
use derive_more::{Display, From};
|
||||||
use logsmash_data::LogLevel;
|
use logsmash_data::LogLevel;
|
||||||
|
|
@ -26,6 +27,8 @@ pub static CUSTOM_TIME_FORMAT: OnceLock<Option<OwnedFormatItem>> = OnceLock::new
|
||||||
|
|
||||||
#[derive(Deserialize, Clone)]
|
#[derive(Deserialize, Clone)]
|
||||||
pub struct LogLine<'a> {
|
pub struct LogLine<'a> {
|
||||||
|
#[serde(default)]
|
||||||
|
pub line_number: LogLineNumber,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub index: LogIndex,
|
pub index: LogIndex,
|
||||||
#[serde(rename = "reqId")]
|
#[serde(rename = "reqId")]
|
||||||
|
|
|
||||||
|
|
@ -15,16 +15,17 @@ use serde::Deserialize;
|
||||||
use std::io::{BufReader, Cursor, Read, Seek};
|
use std::io::{BufReader, Cursor, Read, Seek};
|
||||||
use xz2::read::XzDecoder;
|
use xz2::read::XzDecoder;
|
||||||
|
|
||||||
|
/// The line number of the log within the original log file
|
||||||
#[derive(Debug, Deserialize, Default, Copy, Clone, Ord, PartialOrd, Eq, PartialEq)]
|
#[derive(Debug, Deserialize, Default, Copy, Clone, Ord, PartialOrd, Eq, PartialEq)]
|
||||||
pub struct LogIndex(usize);
|
pub struct LogLineNumber(usize);
|
||||||
|
|
||||||
impl From<usize> for LogIndex {
|
impl From<usize> for LogLineNumber {
|
||||||
fn from(index: usize) -> Self {
|
fn from(index: usize) -> Self {
|
||||||
Self(index)
|
Self(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&usize> for LogIndex {
|
impl From<&usize> for LogLineNumber {
|
||||||
fn from(index: &usize) -> Self {
|
fn from(index: &usize) -> Self {
|
||||||
Self(*index)
|
Self(*index)
|
||||||
}
|
}
|
||||||
|
|
@ -107,7 +108,7 @@ impl LogFile {
|
||||||
self.content.lines()
|
self.content.lines()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn nth(&self, index: LogIndex) -> Option<&str> {
|
pub fn nth(&self, index: LogLineNumber) -> Option<&str> {
|
||||||
self.iter().nth(index.0)
|
self.iter().nth(index.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
86
src/logs.rs
Normal file
86
src/logs.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
use crate::logfile::{LogLine, LogLineNumber};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Error as JsonError;
|
||||||
|
use std::ops::Index;
|
||||||
|
|
||||||
|
/// The index of the log line within the list of parsed logs
|
||||||
|
#[derive(Debug, Deserialize, Default, Copy, Clone, Ord, PartialOrd, Eq, PartialEq)]
|
||||||
|
pub struct LogIndex(usize);
|
||||||
|
|
||||||
|
impl From<usize> for LogIndex {
|
||||||
|
fn from(index: usize) -> Self {
|
||||||
|
Self(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&usize> for LogIndex {
|
||||||
|
fn from(index: &usize) -> Self {
|
||||||
|
Self(*index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ParsedLogs<'logfile> {
|
||||||
|
parsed: Vec<LogLine<'logfile>>,
|
||||||
|
error_lines: Vec<(LogLineNumber, JsonError)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'logfile> ParsedLogs<'logfile> {
|
||||||
|
pub fn all(&self) -> &[LogLine<'logfile>] {
|
||||||
|
&self.parsed
|
||||||
|
}
|
||||||
|
pub fn errors(&self) -> &[(LogLineNumber, JsonError)] {
|
||||||
|
&self.error_lines
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_indices<'a, F: Fn(&'a LogLine<'a>) -> bool>(
|
||||||
|
&'a self,
|
||||||
|
filter: F,
|
||||||
|
) -> impl Iterator<Item = LogIndex> + use<'a, F> {
|
||||||
|
self.parsed
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(move |(_, line)| filter(line))
|
||||||
|
.map(|(i, _)| LogIndex(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn first(&self) -> &LogLine<'logfile> {
|
||||||
|
&self.parsed[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn last(&self) -> &LogLine<'logfile> {
|
||||||
|
&self.parsed[self.parsed.len() - 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> FromIterator<Result<LogLine<'a>, (LogLineNumber, JsonError)>> for ParsedLogs<'a> {
|
||||||
|
fn from_iter<T: IntoIterator<Item = Result<LogLine<'a>, (LogLineNumber, JsonError)>>>(
|
||||||
|
iter: T,
|
||||||
|
) -> Self {
|
||||||
|
let iter = iter.into_iter();
|
||||||
|
let mut parsed_lines = Vec::with_capacity(dbg!(iter.size_hint().0));
|
||||||
|
let mut error_lines = Vec::with_capacity(128);
|
||||||
|
for result in iter {
|
||||||
|
match result {
|
||||||
|
Ok(mut parsed) => {
|
||||||
|
parsed.index = LogIndex(parsed_lines.len());
|
||||||
|
parsed_lines.push(parsed);
|
||||||
|
}
|
||||||
|
Err((line_number, e)) => {
|
||||||
|
error_lines.push((line_number, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ParsedLogs {
|
||||||
|
parsed: parsed_lines,
|
||||||
|
error_lines,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Index<LogIndex> for ParsedLogs<'a> {
|
||||||
|
type Output = LogLine<'a>;
|
||||||
|
|
||||||
|
fn index(&self, index: LogIndex) -> &Self::Output {
|
||||||
|
&self.parsed[index.0]
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/main.rs
73
src/main.rs
|
|
@ -1,6 +1,6 @@
|
||||||
use crate::app::{App, LogMatch};
|
use crate::app::{App, LogMatch};
|
||||||
use crate::error::LogError;
|
use crate::error::LogError;
|
||||||
use crate::logfile::LogFile;
|
use crate::logfile::{LogFile, LogLineNumber};
|
||||||
use crate::matcher::{MatchResult, Matcher};
|
use crate::matcher::{MatchResult, Matcher};
|
||||||
use crate::ui::run_ui;
|
use crate::ui::run_ui;
|
||||||
use base64::prelude::*;
|
use base64::prelude::*;
|
||||||
|
|
@ -19,10 +19,12 @@ use std::iter::once;
|
||||||
mod app;
|
mod app;
|
||||||
mod error;
|
mod error;
|
||||||
mod logfile;
|
mod logfile;
|
||||||
|
mod logs;
|
||||||
mod matcher;
|
mod matcher;
|
||||||
mod timegraph;
|
mod timegraph;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
|
use crate::logs::{LogIndex, ParsedLogs};
|
||||||
#[cfg(target_env = "musl")]
|
#[cfg(target_env = "musl")]
|
||||||
use tikv_jemallocator::Jemalloc;
|
use tikv_jemallocator::Jemalloc;
|
||||||
use time::format_description::{parse_owned, parse_strftime_owned};
|
use time::format_description::{parse_owned, parse_strftime_owned};
|
||||||
|
|
@ -66,9 +68,13 @@ fn main() -> MainResult {
|
||||||
err,
|
err,
|
||||||
path: args.file,
|
path: args.file,
|
||||||
})?;
|
})?;
|
||||||
let lines: Vec<_> = log_file.iter().enumerate().collect();
|
let lines: Vec<_> = log_file
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(line_number, line)| (LogLineNumber::from(line_number), line))
|
||||||
|
.collect();
|
||||||
|
|
||||||
let mut counts: HashMap<MatchResult, Vec<usize>> = HashMap::new();
|
let mut counts: HashMap<MatchResult, Vec<LogIndex>> = HashMap::new();
|
||||||
let Some(first_parsed) = lines.iter().find_map(|(_, line)| parse_line(line).ok()) else {
|
let Some(first_parsed) = lines.iter().find_map(|(_, line)| parse_line(line).ok()) else {
|
||||||
eprintln!("Failed to find at least one log line that parses successfully");
|
eprintln!("Failed to find at least one log line that parses successfully");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -90,51 +96,42 @@ fn main() -> MainResult {
|
||||||
|
|
||||||
let mut results: Vec<_> = lines
|
let mut results: Vec<_> = lines
|
||||||
.into_par_iter()
|
.into_par_iter()
|
||||||
.map(|(index, line)| {
|
.map(|(line_number, line)| {
|
||||||
let mut parsed = parse_line(line);
|
let mut parsed = parse_line(line);
|
||||||
if let Ok(parsed) = parsed.as_mut() {
|
if let Ok(parsed) = parsed.as_mut() {
|
||||||
parsed.index = index.into();
|
parsed.line_number = line_number;
|
||||||
};
|
};
|
||||||
parsed.map_err(|err| (index, line, err))
|
parsed.map_err(|err| (line_number, err))
|
||||||
})
|
})
|
||||||
.map(|parsed| {
|
.progress_with_style(progress_style.clone())
|
||||||
parsed.map(|parsed| {
|
|
||||||
let log_match = matcher.match_log(&parsed);
|
|
||||||
(parsed, log_match)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.progress_with_style(progress_style)
|
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
results.sort_by_key(|res| match res {
|
results.sort_by_key(|res| match res {
|
||||||
Ok((line, _)) => line.index,
|
Ok(line) => line.line_number,
|
||||||
Err((index, _, _)) => index.into(),
|
Err((line_number, _)) => *line_number,
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut error_lines = Vec::with_capacity(32);
|
let parsed_log: ParsedLogs = results.into_iter().collect();
|
||||||
let mut parsed_lines = Vec::with_capacity(results.len());
|
|
||||||
|
let line_matches: Vec<_> = parsed_log
|
||||||
|
.all()
|
||||||
|
.par_iter()
|
||||||
|
.map(|line| (line.index, matcher.match_log(line)))
|
||||||
|
.progress_with_style(progress_style)
|
||||||
|
.collect();
|
||||||
|
|
||||||
let mut unmatched_lines = Vec::with_capacity(256);
|
let mut unmatched_lines = Vec::with_capacity(256);
|
||||||
|
|
||||||
let mut parsed_index = 0;
|
for (index, result) in line_matches.into_iter() {
|
||||||
|
|
||||||
for result in results.into_iter() {
|
|
||||||
match result {
|
match result {
|
||||||
Ok((parsed, Some(match_result))) => {
|
Some(match_result) => {
|
||||||
counts.entry(match_result).or_default().push(parsed_index);
|
counts.entry(match_result).or_default().push(index);
|
||||||
parsed_lines.push(parsed);
|
|
||||||
parsed_index += 1;
|
|
||||||
}
|
}
|
||||||
Ok((parsed, None)) => {
|
None => {
|
||||||
unmatched_lines.push(parsed_index);
|
unmatched_lines.push(index);
|
||||||
parsed_lines.push(parsed);
|
|
||||||
parsed_index += 1;
|
|
||||||
}
|
|
||||||
Err((_index, line, e)) => {
|
|
||||||
error_lines.push((line.to_string(), e));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let error_count = log_file.iter().count() - parsed_lines.len();
|
|
||||||
|
|
||||||
let mut matched_lines: Vec<(_, _)> = counts.into_iter().collect();
|
let mut matched_lines: Vec<(_, _)> = counts.into_iter().collect();
|
||||||
matched_lines.sort_by_key(|(_, lines)| lines.len());
|
matched_lines.sort_by_key(|(_, lines)| lines.len());
|
||||||
|
|
@ -142,24 +139,22 @@ fn main() -> MainResult {
|
||||||
|
|
||||||
let all = LogMatch::new(
|
let all = LogMatch::new(
|
||||||
None,
|
None,
|
||||||
parsed_lines.iter().enumerate().map(|(i, _)| i).collect(),
|
parsed_log.find_indices(|_| true).collect(),
|
||||||
&parsed_lines,
|
&parsed_log,
|
||||||
);
|
);
|
||||||
let unmatched = LogMatch::new(None, unmatched_lines, &parsed_lines);
|
let unmatched = LogMatch::new(None, unmatched_lines, &parsed_log);
|
||||||
|
|
||||||
let matches = matched_lines
|
let matches = matched_lines
|
||||||
.into_par_iter()
|
.into_par_iter()
|
||||||
.map(|(result, lines)| LogMatch::new(Some(result), lines, &parsed_lines))
|
.map(|(result, lines)| LogMatch::new(Some(result), lines, &parsed_log))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let app = App {
|
let app = App {
|
||||||
lines: parsed_lines,
|
lines: parsed_log,
|
||||||
log_statements: statements,
|
log_statements: statements,
|
||||||
error_lines,
|
|
||||||
matches,
|
matches,
|
||||||
unmatched,
|
unmatched,
|
||||||
all,
|
all,
|
||||||
error_count,
|
|
||||||
log_file: &log_file,
|
log_file: &log_file,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use crate::logfile::logline::{Exception, LogLine};
|
use crate::logfile::logline::{Exception, LogLine};
|
||||||
use crate::logfile::LineNumber;
|
use crate::logfile::LineNumber;
|
||||||
use itertools::Either;
|
use itertools::Either;
|
||||||
use logsmash_data::{LogLevel, LoggingStatement, StatementList};
|
use logsmash_data::{LogLevel, LogStatementIndex, LoggingStatement, StatementList};
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::iter::once;
|
use std::iter::once;
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
|
@ -13,11 +13,11 @@ pub struct LogMatch {
|
||||||
exception: Option<&'static str>,
|
exception: Option<&'static str>,
|
||||||
path: &'static str,
|
path: &'static str,
|
||||||
line: LineNumber,
|
line: LineNumber,
|
||||||
index: usize,
|
index: LogStatementIndex,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LogMatch {
|
impl LogMatch {
|
||||||
pub fn new(index: usize, statement: &LoggingStatement) -> LogMatch {
|
pub fn new(index: LogStatementIndex, statement: &LoggingStatement) -> LogMatch {
|
||||||
LogMatch {
|
LogMatch {
|
||||||
level: statement.level,
|
level: statement.level,
|
||||||
pattern: if statement.has_meaningful_message {
|
pattern: if statement.has_meaningful_message {
|
||||||
|
|
@ -46,7 +46,6 @@ impl Matcher {
|
||||||
pub fn new(statements: &StatementList) -> Matcher {
|
pub fn new(statements: &StatementList) -> Matcher {
|
||||||
let mut matches: Vec<_> = statements
|
let mut matches: Vec<_> = statements
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
|
||||||
.map(|(index, statement)| LogMatch::new(index, statement))
|
.map(|(index, statement)| LogMatch::new(index, statement))
|
||||||
.collect();
|
.collect();
|
||||||
matches.sort_by(|a, b| {
|
matches.sort_by(|a, b| {
|
||||||
|
|
@ -124,7 +123,7 @@ impl Matcher {
|
||||||
best_match
|
best_match
|
||||||
}
|
}
|
||||||
|
|
||||||
fn match_exception(&self, exception: &Exception) -> Option<usize> {
|
fn match_exception(&self, exception: &Exception) -> Option<LogStatementIndex> {
|
||||||
for log_match in self.matches.iter() {
|
for log_match in self.matches.iter() {
|
||||||
if log_match.line == exception.line
|
if log_match.line == exception.line
|
||||||
&& log_match.exception == Some(exception.exception.as_ref())
|
&& log_match.exception == Some(exception.exception.as_ref())
|
||||||
|
|
@ -149,8 +148,8 @@ pub fn match_single(pattern: &str, text: &str) -> bool {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Eq)]
|
#[derive(Debug, Clone, Eq)]
|
||||||
pub enum MatchResult {
|
pub enum MatchResult {
|
||||||
Single(usize),
|
Single(LogStatementIndex),
|
||||||
List(Vec<usize>),
|
List(Vec<LogStatementIndex>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PartialEq for MatchResult {
|
impl PartialEq for MatchResult {
|
||||||
|
|
@ -190,7 +189,7 @@ impl MatchResult {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn iter(&self) -> impl Iterator<Item = usize> + '_ {
|
pub fn iter(&self) -> impl Iterator<Item = LogStatementIndex> + '_ {
|
||||||
match self {
|
match self {
|
||||||
MatchResult::Single(index) => Either::Left(once(*index)),
|
MatchResult::Single(index) => Either::Left(once(*index)),
|
||||||
MatchResult::List(list) => Either::Right(list.iter().copied()),
|
MatchResult::List(list) => Either::Right(list.iter().copied()),
|
||||||
|
|
@ -198,6 +197,18 @@ impl MatchResult {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<usize> for MatchResult {
|
||||||
|
fn from(value: usize) -> Self {
|
||||||
|
MatchResult::Single(LogStatementIndex::from(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Vec<usize>> for MatchResult {
|
||||||
|
fn from(value: Vec<usize>) -> Self {
|
||||||
|
MatchResult::List(value.into_iter().map(LogStatementIndex::from).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default, Copy, Clone)]
|
#[derive(Default, Copy, Clone)]
|
||||||
pub struct SingleMatchState<'a> {
|
pub struct SingleMatchState<'a> {
|
||||||
pattern: &'a [u8],
|
pattern: &'a [u8],
|
||||||
|
|
@ -265,7 +276,8 @@ impl<'a> SingleMatchState<'a> {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_matcher() {
|
fn test_matcher() {
|
||||||
use crate::logfile::logline::Exception;
|
use crate::logfile::logline::Exception;
|
||||||
use crate::logfile::LogIndex;
|
use crate::logfile::LogLineNumber;
|
||||||
|
use crate::logs::LogIndex;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use tinystr::TinyAsciiStr;
|
use tinystr::TinyAsciiStr;
|
||||||
|
|
@ -334,7 +346,8 @@ fn test_matcher() {
|
||||||
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(),
|
time: OffsetDateTime::now_utc(),
|
||||||
index: LogIndex::from(0),
|
line_number: LogLineNumber::from(0),
|
||||||
|
index: LogIndex::default(),
|
||||||
method: TinyAsciiStr::from_str("GET").unwrap(),
|
method: TinyAsciiStr::from_str("GET").unwrap(),
|
||||||
remote: TinyAsciiStr::from_str("1.2.3.4").unwrap(),
|
remote: TinyAsciiStr::from_str("1.2.3.4").unwrap(),
|
||||||
user: TinyAsciiStr::from_str("user").unwrap().into(),
|
user: TinyAsciiStr::from_str("user").unwrap().into(),
|
||||||
|
|
@ -344,7 +357,7 @@ fn test_matcher() {
|
||||||
|
|
||||||
let matcher = Matcher::new(&StatementList::new(vec![("", STATEMENTS)]));
|
let matcher = Matcher::new(&StatementList::new(vec![("", STATEMENTS)]));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Some(MatchResult::Single(0)),
|
Some(MatchResult::from(0)),
|
||||||
matcher.match_log(&LogLine {
|
matcher.match_log(&LogLine {
|
||||||
version: "29",
|
version: "29",
|
||||||
app: "core".into(),
|
app: "core".into(),
|
||||||
|
|
@ -354,7 +367,7 @@ fn test_matcher() {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Some(MatchResult::List(vec![3, 4])),
|
Some(MatchResult::from(vec![3, 4])),
|
||||||
matcher.match_log(&LogLine {
|
matcher.match_log(&LogLine {
|
||||||
version: "29",
|
version: "29",
|
||||||
app: "core".into(),
|
app: "core".into(),
|
||||||
|
|
@ -364,7 +377,7 @@ fn test_matcher() {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Some(MatchResult::Single(1)),
|
Some(MatchResult::from(1)),
|
||||||
matcher.match_log(&LogLine {
|
matcher.match_log(&LogLine {
|
||||||
version: "29",
|
version: "29",
|
||||||
app: "core".into(),
|
app: "core".into(),
|
||||||
|
|
@ -384,7 +397,7 @@ fn test_matcher() {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Some(MatchResult::Single(2)),
|
Some(MatchResult::from(2)),
|
||||||
matcher.match_log(
|
matcher.match_log(
|
||||||
&LogLine {
|
&LogLine {
|
||||||
version: "29",
|
version: "29",
|
||||||
|
|
@ -396,7 +409,7 @@ fn test_matcher() {
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Some(MatchResult::Single(4)),
|
Some(MatchResult::from(4)),
|
||||||
matcher.match_log(
|
matcher.match_log(
|
||||||
&LogLine {
|
&LogLine {
|
||||||
version: "29",
|
version: "29",
|
||||||
|
|
@ -414,7 +427,7 @@ fn test_matcher() {
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Some(MatchResult::Single(5)),
|
Some(MatchResult::from(5)),
|
||||||
matcher.match_log(&LogLine {
|
matcher.match_log(&LogLine {
|
||||||
version: "29",
|
version: "29",
|
||||||
app: "core".into(),
|
app: "core".into(),
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,9 @@ pub fn error_list<'a>(app: &'a App<'a>) -> ScrollbarTable<'a> {
|
||||||
.height(1);
|
.height(1);
|
||||||
|
|
||||||
let widths = [Constraint::Percentage(50), Constraint::Percentage(50)];
|
let widths = [Constraint::Percentage(50), Constraint::Percentage(50)];
|
||||||
ScrollbarTable::new(app.error_lines.iter().map(error_row), widths).header(header)
|
ScrollbarTable::new(app.error_lines().map(error_row), widths).header(header)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn error_row((line, err): &(String, serde_json::Error)) -> Row {
|
fn error_row<'a>((line, err): (&'a str, &'a serde_json::Error)) -> Row<'a> {
|
||||||
Row::new([Text::from(format!("{err}")), Text::from(line.as_str())])
|
Row::new([Text::from(format!("{err}")), Text::from(line)])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ pub fn footer<'a>(app: &App<'a>, params: FooterParams<'a>) -> Table<'a> {
|
||||||
[Row::new([
|
[Row::new([
|
||||||
Text::from(help(page)),
|
Text::from(help(page)),
|
||||||
Text::from(format!("{} unmatched items", app.unmatched.count())),
|
Text::from(format!("{} unmatched items", app.unmatched.count())),
|
||||||
Text::from(format!("{} parse errors", app.error_count)),
|
Text::from(format!("{} parse errors", app.error_count())),
|
||||||
])],
|
])],
|
||||||
widths,
|
widths,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use crate::app::{App, Filter};
|
use crate::app::{App, Filter};
|
||||||
use crate::logfile::logline::{format_time, LogLine};
|
use crate::logfile::logline::{format_time, LogLine};
|
||||||
|
use crate::logs::LogIndex;
|
||||||
use crate::ui::state::GroupedLogGrouping;
|
use crate::ui::state::GroupedLogGrouping;
|
||||||
use crate::ui::style::TABLE_HEADER_STYLE;
|
use crate::ui::style::TABLE_HEADER_STYLE;
|
||||||
use crate::ui::table::{ScrollbarTable, ScrollbarTableState};
|
use crate::ui::table::{ScrollbarTable, ScrollbarTableState};
|
||||||
|
|
@ -11,7 +12,7 @@ use ratatui::text::Text;
|
||||||
use ratatui::widgets::{Cell, Paragraph, Row, Wrap};
|
use ratatui::widgets::{Cell, Paragraph, Row, Wrap};
|
||||||
|
|
||||||
pub struct GroupedLogs<'a> {
|
pub struct GroupedLogs<'a> {
|
||||||
lines: &'a [usize],
|
lines: &'a [LogIndex],
|
||||||
app: &'a App<'a>,
|
app: &'a App<'a>,
|
||||||
filter: &'a Filter,
|
filter: &'a Filter,
|
||||||
grouping: GroupedLogGrouping,
|
grouping: GroupedLogGrouping,
|
||||||
|
|
@ -19,7 +20,7 @@ pub struct GroupedLogs<'a> {
|
||||||
|
|
||||||
pub fn grouped_logs<'a>(
|
pub fn grouped_logs<'a>(
|
||||||
app: &'a App<'a>,
|
app: &'a App<'a>,
|
||||||
lines: &'a [usize],
|
lines: &'a [LogIndex],
|
||||||
filter: &'a Filter,
|
filter: &'a Filter,
|
||||||
grouping: GroupedLogGrouping,
|
grouping: GroupedLogGrouping,
|
||||||
) -> GroupedLogs<'a> {
|
) -> GroupedLogs<'a> {
|
||||||
|
|
@ -38,7 +39,7 @@ impl StatefulWidget for GroupedLogs<'_> {
|
||||||
where
|
where
|
||||||
Self: Sized,
|
Self: Sized,
|
||||||
{
|
{
|
||||||
let lines = self.lines.iter().copied().map(|i| &self.app.lines[i]);
|
let lines = self.lines.iter().copied().map(|i| self.app.get_line(i));
|
||||||
let line = self
|
let line = self
|
||||||
.lines
|
.lines
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -46,7 +47,7 @@ impl StatefulWidget for GroupedLogs<'_> {
|
||||||
.map(|i| &self.app.lines[i])
|
.map(|i| &self.app.lines[i])
|
||||||
.filter(|line| line.matches(self.filter))
|
.filter(|line| line.matches(self.filter))
|
||||||
.nth(state.selected())
|
.nth(state.selected())
|
||||||
.unwrap_or(&self.app.lines[0]);
|
.unwrap_or(self.app.lines.first());
|
||||||
|
|
||||||
let par = match self.grouping {
|
let par = match self.grouping {
|
||||||
GroupedLogGrouping::Message => Paragraph::new(format!(
|
GroupedLogGrouping::Message => Paragraph::new(format!(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::app::{App, Filter, GroupedLines, LogMatch};
|
use crate::app::{App, Filter, LineSet, LogMatch};
|
||||||
use crate::ui::style::TABLE_HEADER_STYLE;
|
use crate::ui::style::TABLE_HEADER_STYLE;
|
||||||
use crate::ui::table::{ScrollbarTable, ScrollbarTableState};
|
use crate::ui::table::{ScrollbarTable, ScrollbarTableState};
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
|
|
@ -78,7 +78,7 @@ impl StatefulWidget for SingleMatchTable<'_> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn group_row<'a>(app: &'a App, group: &'a GroupedLines, is_in_view: bool) -> Row<'a> {
|
fn group_row<'a>(app: &'a App, group: &'a LineSet, is_in_view: bool) -> Row<'a> {
|
||||||
if is_in_view {
|
if is_in_view {
|
||||||
let line = &app.lines[group.lines[0]];
|
let line = &app.lines[group.lines[0]];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use crate::app::{App, Filter, LogMatch, EMPTY_FILTER};
|
use crate::app::{App, Filter, LogMatch, EMPTY_FILTER};
|
||||||
use crate::error::ParseError;
|
use crate::error::ParseError;
|
||||||
use crate::logfile::logline::{FullLogLine, LogLine};
|
use crate::logfile::logline::{FullLogLine, LogLine};
|
||||||
|
use crate::logs::LogIndex;
|
||||||
use crate::ui::footer::FooterParams;
|
use crate::ui::footer::FooterParams;
|
||||||
use crate::ui::input::{PopMode, UiEvent};
|
use crate::ui::input::{PopMode, UiEvent};
|
||||||
use crate::ui::table::ScrollbarTableState;
|
use crate::ui::table::ScrollbarTableState;
|
||||||
|
|
@ -132,7 +133,7 @@ pub enum GroupedLogGrouping {
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct GroupedLogsState<'a> {
|
pub struct GroupedLogsState<'a> {
|
||||||
pub lines: Cow<'a, [usize]>,
|
pub lines: Cow<'a, [LogIndex]>,
|
||||||
pub table_state: ScrollbarTableState,
|
pub table_state: ScrollbarTableState,
|
||||||
pub previous: Box<UiState<'a>>,
|
pub previous: Box<UiState<'a>>,
|
||||||
pub filter: Filter,
|
pub filter: Filter,
|
||||||
|
|
@ -161,7 +162,7 @@ impl<'a> GroupedLogsState<'a> {
|
||||||
|
|
||||||
fn enter(self, selected: usize, app: &'a App<'a>) -> UiState<'a> {
|
fn enter(self, selected: usize, app: &'a App<'a>) -> UiState<'a> {
|
||||||
let log = self.get_selected(selected, app);
|
let log = self.get_selected(selected, app);
|
||||||
let raw_line = app.get_line(log.index).unwrap();
|
let raw_line = app.get_source_line(log.line_number).unwrap();
|
||||||
let full_line = match parse_line_full(raw_line) {
|
let full_line = match parse_line_full(raw_line) {
|
||||||
Ok(line) => line,
|
Ok(line) => line,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
|
@ -249,7 +250,7 @@ impl<'a> LogState<'a> {
|
||||||
|
|
||||||
impl PartialEq for LogState<'_> {
|
impl PartialEq for LogState<'_> {
|
||||||
fn eq(&self, other: &Self) -> bool {
|
fn eq(&self, other: &Self) -> bool {
|
||||||
self.log.index == other.log.index
|
self.log.line_number == other.log.line_number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -435,7 +436,7 @@ impl<'a> UiState<'a> {
|
||||||
(true, state.enter(selected, app))
|
(true, state.enter(selected, app))
|
||||||
}
|
}
|
||||||
(UiState::MatchList(state), UiEvent::Errors) => {
|
(UiState::MatchList(state), UiEvent::Errors) => {
|
||||||
let table_state = ScrollbarTableState::new(app.error_lines.len());
|
let table_state = ScrollbarTableState::new(app.error_count());
|
||||||
(
|
(
|
||||||
true,
|
true,
|
||||||
UiState::Errors(ErrorLinesState {
|
UiState::Errors(ErrorLinesState {
|
||||||
|
|
@ -462,12 +463,14 @@ impl<'a> UiState<'a> {
|
||||||
table_state.select(Some(0));
|
table_state.select(Some(0));
|
||||||
|
|
||||||
let line = &app.lines[state.lines[selected]];
|
let line = &app.lines[state.lines[selected]];
|
||||||
let raw = app.get_line(line.index).unwrap_or_default();
|
let raw = app.get_source_line(line.line_number).unwrap_or_default();
|
||||||
copy_osc(raw);
|
copy_osc(raw);
|
||||||
(false, UiState::GroupedLogs(state))
|
(false, UiState::GroupedLogs(state))
|
||||||
}
|
}
|
||||||
(UiState::Log(state), UiEvent::Copy) => {
|
(UiState::Log(state), UiEvent::Copy) => {
|
||||||
let raw = app.get_line(state.log.index).unwrap_or_default();
|
let raw = app
|
||||||
|
.get_source_line(state.log.line_number)
|
||||||
|
.unwrap_or_default();
|
||||||
copy_osc(raw);
|
copy_osc(raw);
|
||||||
(false, UiState::Log(state))
|
(false, UiState::Log(state))
|
||||||
}
|
}
|
||||||
|
|
@ -478,9 +481,9 @@ impl<'a> UiState<'a> {
|
||||||
(UiState::Log(state), UiEvent::ByRequest) => (true, state.by_request(app)),
|
(UiState::Log(state), UiEvent::ByRequest) => (true, state.by_request(app)),
|
||||||
(UiState::Errors(state), UiEvent::Copy) => {
|
(UiState::Errors(state), UiEvent::Copy) => {
|
||||||
let raw = app
|
let raw = app
|
||||||
.error_lines
|
.error_lines()
|
||||||
.get(state.table_state.selected())
|
.nth(state.table_state.selected())
|
||||||
.map(|(line, _)| line.as_str())
|
.map(|(line, _)| line)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
copy_osc(raw);
|
copy_osc(raw);
|
||||||
(false, UiState::Errors(state))
|
(false, UiState::Errors(state))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue