use std::collections::VecDeque;
use std::sync::Arc;
use eframe::egui;
use parking_lot::Mutex;
use crate::types::LogEntry;
use super::super::icons::Icon;
use super::super::log_view::{self, LogLine};
use super::super::widgets;
pub const LOGS_WINDOW: usize = 500;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogFilter {
pub level: LevelFilter,
pub search: String,
pub auto_scroll: bool,
}
impl Default for LogFilter {
fn default() -> Self {
Self {
level: LevelFilter::All,
search: String::new(),
auto_scroll: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LevelFilter {
All,
Info,
Warn,
Error,
}
impl LevelFilter {
pub const ALL: [LevelFilter; 4] = [
LevelFilter::All,
LevelFilter::Info,
LevelFilter::Warn,
LevelFilter::Error,
];
pub fn label(self) -> &'static str {
match self {
LevelFilter::All => "All",
LevelFilter::Info => "Info",
LevelFilter::Warn => "Warn",
LevelFilter::Error => "Error",
}
}
pub fn matches(self, entry_level: &str) -> bool {
match self {
LevelFilter::All => true,
LevelFilter::Info => entry_level == "info",
LevelFilter::Warn => entry_level == "warn",
LevelFilter::Error => entry_level == "error",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct LogsView {
pub entries: Vec<LogEntry>,
pub windowed: bool,
pub total_buffer: usize,
}
impl LogsView {
pub fn summary(&self) -> String {
if self.windowed {
format!(
"Showing the last {} matching entries of {} kept",
self.entries.len(),
self.total_buffer
)
} else {
format!("{} of {} entries", self.entries.len(), self.total_buffer)
}
}
pub fn build(buffer: &[LogEntry], filter: &LogFilter, window: usize) -> Self {
let needle = filter.search.trim().to_lowercase();
let filtered: Vec<LogEntry> = buffer
.iter()
.filter(|e| filter.level.matches(&e.level))
.filter(|e| {
needle.is_empty()
|| e.message.to_lowercase().contains(&needle)
|| e.category.to_lowercase().contains(&needle)
|| e.job_id
.as_deref()
.map(|j| j.to_lowercase().contains(&needle))
.unwrap_or(false)
})
.cloned()
.collect();
let total_buffer = buffer.len();
let windowed = filtered.len() > window;
let entries = if windowed {
filtered[filtered.len() - window..].to_vec()
} else {
filtered
};
Self {
entries,
windowed,
total_buffer,
}
}
}
pub fn render(ui: &mut egui::Ui, buffer: &Arc<Mutex<VecDeque<LogEntry>>>, filter: &mut LogFilter) {
widgets::page_title(
ui,
"Logs",
"Everything the daemon logs at info and up, newest at the bottom.",
);
let view = {
let buf = buffer.lock();
let snapshot: Vec<LogEntry> = buf.iter().cloned().collect();
LogsView::build(&snapshot, filter, LOGS_WINDOW)
};
let lines: Vec<LogLine> = view
.entries
.iter()
.map(|e| LogLine::from_entry(e, &chrono::Local))
.collect();
let composed = log_view::compose(&lines);
ui.horizontal(|ui| {
for level in LevelFilter::ALL {
ui.selectable_value(&mut filter.level, level, level.label());
}
ui.add_space(12.0);
ui.add(
egui::TextEdit::singleline(&mut filter.search)
.desired_width(260.0)
.hint_text("Search category, message or job id"),
);
ui.add_space(12.0);
ui.checkbox(&mut filter.auto_scroll, "Follow")
.on_hover_text("keep the newest line in view");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
widgets::copy_button(ui, "logs", "Copy", &composed.text);
});
});
ui.add_space(4.0);
ui.label(widgets::muted(ui, view.summary()).small());
ui.add_space(6.0);
let height = (ui.available_height() - 24.0).max(160.0);
if view.entries.is_empty() {
widgets::card(ui, |ui| {
ui.set_min_height(height - 2.0 * f32::from(widgets::CARD_PADDING));
widgets::empty_state(
ui,
Icon::Logs,
"No log entries match",
"Clear the search or pick another level.",
);
});
return;
}
log_view::show(ui, "worker-log", &composed, filter.auto_scroll, height);
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(level: &str, category: &str, message: &str, job_id: Option<&str>) -> LogEntry {
LogEntry {
ts: "2026-05-25T10:00:00Z".into(),
level: level.into(),
category: category.into(),
message: message.into(),
job_id: job_id.map(str::to_string),
}
}
#[test]
fn build_with_default_filter_returns_everything() {
let buf = vec![
entry("info", "claim", "a", None),
entry("warn", "heartbeat", "b", None),
];
let view = LogsView::build(&buf, &LogFilter::default(), LOGS_WINDOW);
assert_eq!(view.entries.len(), 2);
assert!(!view.windowed);
}
#[test]
fn level_filter_excludes_other_levels() {
let buf = vec![
entry("info", "x", "a", None),
entry("warn", "x", "b", None),
entry("error", "x", "c", None),
];
let filter = LogFilter {
level: LevelFilter::Error,
..LogFilter::default()
};
let view = LogsView::build(&buf, &filter, LOGS_WINDOW);
assert_eq!(view.entries.len(), 1);
assert_eq!(view.entries[0].level, "error");
}
#[test]
fn search_matches_message_category_or_job_id_case_insensitive() {
let buf = vec![
entry("info", "claim", "Boom and bust", None),
entry("info", "Boom", "noise", None),
entry("info", "x", "y", Some("Boom-1")),
entry("info", "z", "unrelated", None),
];
let filter = LogFilter {
search: "boom".into(),
..LogFilter::default()
};
let view = LogsView::build(&buf, &filter, LOGS_WINDOW);
assert_eq!(view.entries.len(), 3);
}
#[test]
fn windows_to_last_n_when_buffer_exceeds_cap() {
let buf: Vec<LogEntry> = (0..1000)
.map(|i| entry("info", "x", &format!("m{i}"), None))
.collect();
let view = LogsView::build(&buf, &LogFilter::default(), 500);
assert_eq!(view.entries.len(), 500);
assert!(view.windowed);
assert_eq!(view.entries.last().unwrap().message, "m999");
assert_eq!(view.entries.first().unwrap().message, "m500");
}
#[test]
fn the_summary_says_how_much_is_shown() {
let buf: Vec<LogEntry> = (0..10)
.map(|i| entry("info", "x", &format!("m{i}"), None))
.collect();
let view = LogsView::build(&buf, &LogFilter::default(), 4);
assert_eq!(
view.summary(),
"Showing the last 4 matching entries of 10 kept"
);
let view = LogsView::build(&buf, &LogFilter::default(), LOGS_WINDOW);
assert_eq!(view.summary(), "10 of 10 entries");
let labels: Vec<_> = LevelFilter::ALL.iter().map(|l| l.label()).collect();
assert_eq!(labels, ["All", "Info", "Warn", "Error"]);
}
#[test]
fn the_page_draws_with_entries_and_without() {
let ring = Arc::new(Mutex::new(VecDeque::from(vec![
entry("warn", "heartbeat", "late", Some("j-1")),
entry("error", "claim", "boom", None),
])));
let mut filter = LogFilter::default();
egui::__run_test_ui(|ui| render(ui, &ring, &mut filter));
filter.search = "nothing matches this".into();
egui::__run_test_ui(|ui| render(ui, &ring, &mut filter));
}
#[test]
fn auto_scroll_default_is_on() {
assert!(LogFilter::default().auto_scroll);
}
}