use egui_extras::{Column, TableBuilder};
use gantz_core::node;
use log::{Level, Metadata, Record};
use std::{
collections::{HashMap, VecDeque},
sync::{Arc, Mutex},
};
use web_time::SystemTime;
pub struct LogView<'a> {
logger: Logger,
id: egui::Id,
node_labels: Option<&'a HashMap<Vec<node::Id>, String>>,
}
#[derive(Default)]
pub struct LogViewResponse {
pub clicked_path: Option<Vec<node::Id>>,
}
#[derive(Clone)]
struct LogViewState {
level_filter: log::LevelFilter,
text_filter: String,
auto_scroll: bool,
show_time: bool,
show_date: bool,
show_level: bool,
show_target: bool,
}
#[derive(Clone)]
pub struct LogEntry {
pub level: Level,
pub message: String,
pub target: String,
pub timestamp: SystemTime,
}
impl LogEntry {
fn format_timestamp(&self, show_date: bool) -> String {
let system_time = crate::system_time_from_web(self.timestamp).expect("failed to convert");
if show_date {
crate::widget::format_local_datetime(system_time)
} else {
crate::widget::format_local_time(system_time)
}
}
fn freshness(&self) -> f32 {
let now = SystemTime::now();
if let Ok(duration) = now.duration_since(self.timestamp) {
if duration.as_secs_f32() >= 1.0 {
return 0.0;
}
return (1.0 - duration.as_secs_f32()).powf(3.0);
}
0.0
}
}
#[derive(Clone)]
pub struct Logger {
entries: Arc<Mutex<VecDeque<LogEntry>>>,
max_entries: usize,
}
impl Logger {
pub const DEFAULT_MAX_ENTRIES: usize = 1_000;
pub fn new(max_entries: usize) -> Self {
Self {
entries: Arc::new(Mutex::new(VecDeque::new())),
max_entries,
}
}
pub fn get_entries(&self) -> Vec<LogEntry> {
self.entries.lock().unwrap().iter().cloned().collect()
}
pub fn clear(&self) {
self.entries.lock().unwrap().clear();
}
}
impl Default for Logger {
fn default() -> Self {
Self::new(Self::DEFAULT_MAX_ENTRIES)
}
}
impl log::Log for Logger {
fn enabled(&self, _metadata: &Metadata) -> bool {
true
}
fn log(&self, record: &Record) {
let entry = LogEntry {
level: record.level(),
message: record.args().to_string(),
target: record.target().to_string(),
timestamp: SystemTime::now(),
};
let mut entries = self.entries.lock().unwrap();
entries.push_back(entry);
while entries.len() > self.max_entries {
entries.pop_front();
}
}
fn flush(&self) {}
}
impl<'a> LogView<'a> {
pub fn new(id: egui::Id, logger: Logger) -> Self {
Self {
logger,
id,
node_labels: None,
}
}
pub fn node_labels(mut self, labels: &'a HashMap<Vec<node::Id>, String>) -> Self {
self.node_labels = Some(labels);
self
}
pub fn show(&mut self, ui: &mut egui::Ui) -> LogViewResponse {
let mut response = LogViewResponse::default();
let state_id = self.id.with("state");
let mut state = ui
.memory_mut(|mem| mem.data.get_temp::<LogViewState>(state_id))
.unwrap_or_else(|| LogViewState {
level_filter: log::max_level(),
text_filter: String::new(),
auto_scroll: true,
show_time: true,
show_date: true,
show_level: true,
show_target: false,
});
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let h = ui.spacing().interact_size.y;
let btn = ui
.add_sized([h, h], egui::Button::new(crate::widget::OPTIONS_GLYPH))
.on_hover_text("log options");
egui::Popup::menu(&btn)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show(|ui| {
ui.horizontal(|ui| {
ui.label("Level:");
let init = state.level_filter;
egui::ComboBox::from_id_salt(self.id.with("level_filter"))
.selected_text(format!("{}", state.level_filter))
.show_ui(ui, |ui| {
use log::LevelFilter as L;
ui.selectable_value(&mut state.level_filter, L::Off, "Off");
ui.selectable_value(&mut state.level_filter, L::Error, "Error");
ui.selectable_value(&mut state.level_filter, L::Warn, "Warn");
ui.selectable_value(&mut state.level_filter, L::Info, "Info");
ui.selectable_value(&mut state.level_filter, L::Debug, "Debug");
ui.selectable_value(&mut state.level_filter, L::Trace, "Trace");
});
if init != state.level_filter {
log::set_max_level(state.level_filter);
}
});
ui.checkbox(&mut state.auto_scroll, "Auto-scroll");
ui.separator();
ui.label("Columns:");
ui.checkbox(&mut state.show_time, "Time");
ui.add_enabled(
state.show_time,
egui::Checkbox::new(&mut state.show_date, "Date"),
)
.on_hover_text("include the date in the Time column");
ui.checkbox(&mut state.show_level, "Level");
ui.checkbox(&mut state.show_target, "Target");
ui.separator();
if ui.button("Clear").clicked() {
self.logger.clear();
}
});
egui::TextEdit::singleline(&mut state.text_filter)
.desired_width(ui.available_width())
.hint_text("🔎 Filter")
.show(ui);
});
});
ui.separator();
let mut entries = self.logger.get_entries();
entries.retain(|entry| entry.level <= state.level_filter);
if !state.text_filter.is_empty() {
let filter = state.text_filter.to_lowercase();
entries.retain(|entry| {
let hay = format!("{} {}", entry.message, entry.target).to_lowercase();
filter.split_whitespace().all(|term| hay.contains(term))
});
}
entries.reverse();
let runs = crate::widget::group_runs(&entries, |a, b| {
a.level == b.level && a.message == b.message && a.target == b.target
});
let (show_time, show_date, show_level, show_target) = (
state.show_time,
state.show_date,
state.show_level,
state.show_target,
);
let mut table = TableBuilder::new(ui).striped(true).resizable(true);
if show_time {
table = table.column(Column::auto().at_least(80.0)); }
if show_level {
table = table.column(Column::auto().at_least(50.0)); }
if show_target {
table = table.column(Column::auto().at_least(80.0)); }
table
.column(Column::remainder().at_least(100.0)) .header(20.0, |mut header| {
if show_time {
header.col(|ui| {
ui.strong("Time");
});
}
if show_level {
header.col(|ui| {
ui.strong("Level");
});
}
if show_target {
header.col(|ui| {
ui.strong("Target");
});
}
header.col(|ui| {
ui.strong("Message");
});
})
.body(|mut body| {
let row_h = 18.0;
let n_rows = runs.len();
let text_color = body.ui_mut().style().visuals.text_color();
body.rows(row_h, n_rows, |mut row| {
let (idx, count) = runs[row.index()];
let entry = &entries[idx];
let freshness = entry.freshness();
let fresh = freshness > 0.0;
let text_color = if fresh {
let hl_col = egui::Color32::WHITE;
text_color.lerp_to_gamma(hl_col, freshness)
} else {
text_color
};
if show_time {
row.col(|ui| {
let text = entry.format_timestamp(show_date);
ui.colored_label(text_color, text);
});
}
if show_level {
row.col(|ui| {
let (color, text) = match entry.level {
Level::Error => (egui::Color32::from_rgb(255, 100, 100), "ERROR"),
Level::Warn => (egui::Color32::from_rgb(255, 200, 100), "WARN"),
Level::Info => (egui::Color32::from_rgb(100, 200, 255), "INFO"),
Level::Debug => (egui::Color32::GRAY, "DEBUG"),
Level::Trace => (egui::Color32::DARK_GRAY, "TRACE"),
};
ui.colored_label(color, text);
});
}
if show_target {
row.col(|ui| {
let node_path = gantz_std::log::parse_log_target(&entry.target);
match node_path {
Some(path) => {
let label = self
.node_labels
.and_then(|labels| labels.get(&path))
.map(String::as_str);
let ids: Vec<String> =
path.iter().map(ToString::to_string).collect();
let text = match label {
Some(label) => format!("{label} ({})", ids.join(":")),
None => ids.join(":"),
};
if ui.link(text).on_hover_text("select node").clicked() {
response.clicked_path = Some(path);
}
}
None => {
ui.colored_label(text_color, &entry.target);
}
}
});
}
row.col(|ui| {
ui.horizontal(|ui| {
if count > 1 {
ui.colored_label(
text_color.gamma_multiply(0.7),
format!("×{count}"),
)
.on_hover_text("occurrences collapsed (repeated log)");
}
ui.colored_label(text_color, &entry.message);
});
});
if fresh {
row.response().ctx.request_repaint();
}
});
});
if state.auto_scroll {
ui.scroll_to_cursor(Some(egui::Align::BOTTOM));
}
ui.memory_mut(|mem| mem.data.insert_temp(state_id, state));
response
}
}