380 lines
14 KiB
Rust
380 lines
14 KiB
Rust
#![allow(non_snake_case)]
|
|
#![cfg_attr(target_os = "windows", windows_subsystem = "windows")]
|
|
|
|
use std::{fs::File, io::Read};
|
|
|
|
use clap::Parser;
|
|
use dioxus::{desktop::WindowBuilder, prelude::*};
|
|
use dioxus_logger::tracing::{info, Level};
|
|
use regex::RegexBuilder;
|
|
|
|
#[derive(Clone, Parser)]
|
|
struct Args {
|
|
log_file: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct LogEntry {
|
|
timestamp: String,
|
|
level: LogLevel,
|
|
category: String,
|
|
thread: String,
|
|
source: String,
|
|
message: String,
|
|
}
|
|
|
|
impl LogEntry {
|
|
fn level_class(&self) -> &str {
|
|
match self.level {
|
|
LogLevel::Info => "text-blue-500",
|
|
LogLevel::Warning => "text-yellow-500",
|
|
LogLevel::Error => "text-red-500",
|
|
_ => "text-gray-500",
|
|
}
|
|
}
|
|
|
|
fn matches_query(&self, query: &str) -> bool {
|
|
let query = query.trim().to_lowercase();
|
|
if query.is_empty() {
|
|
return true;
|
|
}
|
|
self.timestamp.to_lowercase().contains(&query)
|
|
|| self.level.as_str().to_lowercase().contains(&query)
|
|
|| self.category.to_lowercase().contains(&query)
|
|
|| self.thread.to_lowercase().contains(&query)
|
|
|| self.source.to_lowercase().contains(&query)
|
|
|| self.message.to_lowercase().contains(&query)
|
|
}
|
|
}
|
|
|
|
fn highlight_matches<'a>(text: &'a str, query: &str) -> Vec<(&'a str, bool)> {
|
|
if query.is_empty() {
|
|
return vec![(text, false)];
|
|
}
|
|
let mut result = Vec::new();
|
|
let mut last_end = 0;
|
|
|
|
let re = RegexBuilder::new(®ex::escape(query))
|
|
.case_insensitive(true)
|
|
.build()
|
|
.unwrap();
|
|
|
|
for mat in re.find_iter(text) {
|
|
if mat.start() > last_end {
|
|
result.push((&text[last_end..mat.start()], false));
|
|
}
|
|
result.push((&text[mat.start()..mat.end()], true));
|
|
last_end = mat.end();
|
|
}
|
|
|
|
if last_end < text.len() {
|
|
result.push((&text[last_end..], false));
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Debug)]
|
|
enum LogLevel {
|
|
Trace,
|
|
Debug,
|
|
Info,
|
|
Warning,
|
|
Error,
|
|
Unknown,
|
|
}
|
|
|
|
impl LogLevel {
|
|
fn as_str(&self) -> &'static str {
|
|
match self {
|
|
LogLevel::Trace => "TRACE",
|
|
LogLevel::Debug => "DEBUG",
|
|
LogLevel::Info => "INFO",
|
|
LogLevel::Warning => "WARN",
|
|
LogLevel::Error => "ERROR",
|
|
LogLevel::Unknown => "UNKNOWN",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&str> for LogLevel {
|
|
fn from(s: &str) -> Self {
|
|
match s.to_uppercase().as_str() {
|
|
"TRACE" => LogLevel::Trace,
|
|
"DEBUG" => LogLevel::Debug,
|
|
"INFO" => LogLevel::Info,
|
|
"WARNING" => LogLevel::Warning,
|
|
"ERROR" => LogLevel::Error,
|
|
_ => LogLevel::Unknown,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Routable, Debug, PartialEq)]
|
|
enum Route {
|
|
#[route("/")]
|
|
Home {},
|
|
}
|
|
|
|
fn make_config() -> dioxus::desktop::Config {
|
|
// dioxus::desktop::Config::new()
|
|
// .with_custom_head(r#"<script src="/assets/tailwind.js"></script>"#.to_string())
|
|
let cfg = dioxus::desktop::Config::new();
|
|
#[cfg(not(debug_assertions))]
|
|
let cfg = cfg.with_disable_context_menu(true);
|
|
#[cfg(not(debug_assertions))]
|
|
let cfg = cfg.with_menu(None);
|
|
cfg.with_custom_head(r#"<link rel="stylesheet" href="assets/tailwind.css">"#.to_string())
|
|
.with_window(make_window())
|
|
}
|
|
|
|
fn make_window() -> WindowBuilder {
|
|
WindowBuilder::new()
|
|
.with_resizable(true)
|
|
.with_always_on_top(false)
|
|
}
|
|
|
|
fn main() {
|
|
// Init logger
|
|
dioxus_logger::init(Level::ERROR).expect("failed to init logger");
|
|
info!("starting app");
|
|
|
|
LaunchBuilder::desktop().with_cfg(make_config()).launch(App);
|
|
}
|
|
|
|
#[component]
|
|
fn App() -> Element {
|
|
rsx! {
|
|
Router::<Route> {}
|
|
}
|
|
}
|
|
|
|
fn parse_log_file(content: &str) -> Vec<LogEntry> {
|
|
let mut entries = Vec::new();
|
|
for line in content.lines() {
|
|
let parts: Vec<&str> = line.split('\t').collect();
|
|
if parts.len() >= 6 {
|
|
entries.push(LogEntry {
|
|
timestamp: parts[0].to_string(),
|
|
level: LogLevel::from(parts[1]),
|
|
category: parts[2].to_string(),
|
|
thread: parts[3].to_string(),
|
|
source: parts[4].to_string(),
|
|
message: parts[5..].join(" "),
|
|
});
|
|
}
|
|
}
|
|
entries
|
|
}
|
|
|
|
fn read_log_file(filename: &str) -> Vec<LogEntry> {
|
|
let mut entries = Vec::new();
|
|
if let Ok(mut file) = File::open(filename) {
|
|
let mut contents = String::new();
|
|
if file.read_to_string(&mut contents).is_ok() {
|
|
entries = parse_log_file(&contents);
|
|
}
|
|
}
|
|
entries
|
|
}
|
|
|
|
#[component]
|
|
fn Home() -> Element {
|
|
let mut log_entries = use_signal(|| {
|
|
let Args { log_file } = Args::parse();
|
|
log_file
|
|
.map(|log_file| read_log_file(&log_file))
|
|
.unwrap_or_default()
|
|
});
|
|
|
|
let mut search_query = use_signal(String::new);
|
|
let mut highlight = use_signal(String::new);
|
|
|
|
// State for column widths
|
|
let column_widths = [150.0, 80.0, 100.0, 140.0, 300.0, 150.0]; // Initial widths for 6 columns
|
|
|
|
// Columns visibility
|
|
let mut column_visibility = use_signal(|| vec![true; 6]); // All columns visible by default
|
|
|
|
// Column titles
|
|
let column_titles = [
|
|
"Timestamp",
|
|
"Level",
|
|
"Category",
|
|
"Thread",
|
|
"Source",
|
|
"Message",
|
|
];
|
|
|
|
rsx! {
|
|
div { class: "p-4",
|
|
// Search input
|
|
div {
|
|
class: "header",
|
|
div {
|
|
style: "display: flex;",
|
|
input {
|
|
style: "flex: 1;",
|
|
class: "border w-full mb-2",
|
|
r#type: "text",
|
|
value: "{search_query}",
|
|
placeholder: "Search...",
|
|
oninput: move |e| {
|
|
search_query.set(e.value());
|
|
}
|
|
}
|
|
input {
|
|
style: "flex: 1; margin-left: 8px;",
|
|
class: "border w-full mb-2",
|
|
r#type: "text",
|
|
value: "{highlight}",
|
|
placeholder: "Highlight...",
|
|
oninput: move |e| {
|
|
highlight.set(e.value());
|
|
}
|
|
},
|
|
input {
|
|
id: "files",
|
|
style: "display:none;",
|
|
class: "hidden",
|
|
r#type: "file",
|
|
onchange: move |e| {
|
|
async move {
|
|
if let Some(file_engine) = e.files() {
|
|
let files = file_engine.files();
|
|
if let Some(file) = file_engine.read_file_to_string(&files[0]).await
|
|
{
|
|
log_entries.set(parse_log_file(&file));
|
|
}
|
|
}
|
|
}
|
|
},
|
|
},
|
|
label {
|
|
r#for: "files",
|
|
class: "border w-full mb-2 bg-gray-200",
|
|
style: "flex: 1; margin-left: 8px;",
|
|
"Open log file"
|
|
}
|
|
}
|
|
|
|
// Column visibility checkboxes
|
|
div { class: "mb-4",
|
|
for (i, title) in column_titles.iter().enumerate() {
|
|
label { class: "mr-4",
|
|
input {
|
|
r#type: "checkbox",
|
|
checked: "{column_visibility()[i]}",
|
|
onchange: move |_| {
|
|
let mut visibility = column_visibility();
|
|
visibility[i] = !visibility[i];
|
|
column_visibility.set(visibility);
|
|
}
|
|
}
|
|
" {title} "
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
div {
|
|
class: "content",
|
|
// Log entries table
|
|
table { class: "table-auto w-full text-left",
|
|
thead { class: "bg-gray-200",
|
|
style: "position: sticky; top: 0;",
|
|
tr {
|
|
for (i, title) in column_titles.iter().enumerate() {
|
|
if column_visibility()[i] {
|
|
th {
|
|
class: "px-4 py-2 relative",
|
|
// Column title
|
|
"{title}"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
tbody {
|
|
for entry in log_entries().iter().filter(|entry| {
|
|
entry.matches_query(&search_query())
|
|
}) {
|
|
tr { class: "hover:bg-gray-100",
|
|
for i in 0..6 {
|
|
if column_visibility()[i] {
|
|
match i {
|
|
0 => rsx! { td {
|
|
class: "resize-null",
|
|
style: "width: {column_widths[i]}px;min-width: {column_widths[i]}px;max-width: {column_widths[i]}px;",
|
|
for (text, is_highlighted) in highlight_matches(&entry.timestamp, &highlight()) {
|
|
if is_highlighted {
|
|
span { class: "bg-yellow-200", "{text}" }
|
|
} else {
|
|
"{text}"
|
|
}
|
|
}
|
|
} },
|
|
1 => rsx! { td {
|
|
class: "resize-null {entry.level_class()}",
|
|
style: "width: {column_widths[i]}px;min-width: {column_widths[i]}px;max-width: {column_widths[i]}px;",
|
|
for (text, is_highlighted) in highlight_matches(&entry.level.as_str(), &highlight()) {
|
|
if is_highlighted {
|
|
span { class: "bg-yellow-200", "{text}" }
|
|
} else {
|
|
"{text}"
|
|
}
|
|
}
|
|
} },
|
|
2 => rsx! { td {
|
|
class: "resize-null",
|
|
style: "width: {column_widths[i]}px;min-width: {column_widths[i]}px;max-width: {column_widths[i]}px;",
|
|
for (text, is_highlighted) in highlight_matches(&entry.category, &highlight()) {
|
|
if is_highlighted {
|
|
span { class: "bg-yellow-200", "{text}" }
|
|
} else {
|
|
"{text}"
|
|
}
|
|
}
|
|
} },
|
|
3 => rsx! { td {
|
|
class: "resize-null",
|
|
style: "width: {column_widths[i]}px;min-width: {column_widths[i]}px;max-width: {column_widths[i]}px;",
|
|
for (text, is_highlighted) in highlight_matches(&entry.thread, &highlight()) {
|
|
if is_highlighted {
|
|
span { class: "bg-yellow-200", "{text}" }
|
|
} else {
|
|
"{text}"
|
|
}
|
|
}
|
|
} },
|
|
4 => rsx! { td {
|
|
for (text, is_highlighted) in highlight_matches(&entry.source, &highlight()) {
|
|
if is_highlighted {
|
|
span { class: "bg-yellow-200", "{text}" }
|
|
} else {
|
|
"{text}"
|
|
}
|
|
}
|
|
} },
|
|
5 => rsx! { td {
|
|
for (text, is_highlighted) in highlight_matches(&entry.message, &highlight()) {
|
|
if is_highlighted {
|
|
span { class: "bg-yellow-200", "{text}" }
|
|
} else {
|
|
"{text}"
|
|
}
|
|
}
|
|
} },
|
|
_ => rsx!(),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
}
|
|
}
|
|
}
|