old-fast-log-viewer/src/main.rs

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(&regex::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!(),
}
}
}
}
}
}
}
},
}
}
}