#![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, } #[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#""#.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#""#.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:: {} } } fn parse_log_file(content: &str) -> Vec { 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 { 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!(), } } } } } } } }, } } }