1use std::collections::VecDeque;
7use std::sync::Arc;
8
9use eframe::egui;
10use parking_lot::Mutex;
11
12use crate::types::LogEntry;
13
14pub const LOGS_WINDOW: usize = 500;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct LogFilter {
18 pub level: LevelFilter,
19 pub search: String,
20 pub auto_scroll: bool,
21}
22
23impl Default for LogFilter {
24 fn default() -> Self {
25 Self {
26 level: LevelFilter::All,
27 search: String::new(),
28 auto_scroll: true,
29 }
30 }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum LevelFilter {
35 All,
36 Info,
37 Warn,
38 Error,
39}
40
41impl LevelFilter {
42 pub fn label(self) -> &'static str {
43 match self {
44 LevelFilter::All => "all",
45 LevelFilter::Info => "info",
46 LevelFilter::Warn => "warn",
47 LevelFilter::Error => "error",
48 }
49 }
50
51 pub fn matches(self, entry_level: &str) -> bool {
52 match self {
53 LevelFilter::All => true,
54 LevelFilter::Info => entry_level == "info",
55 LevelFilter::Warn => entry_level == "warn",
56 LevelFilter::Error => entry_level == "error",
57 }
58 }
59}
60
61#[derive(Debug, Clone, PartialEq)]
63pub struct LogsView {
64 pub entries: Vec<LogEntry>,
65 pub windowed: bool,
68 pub total_buffer: usize,
69}
70
71impl LogsView {
72 pub fn build(buffer: &[LogEntry], filter: &LogFilter, window: usize) -> Self {
73 let needle = filter.search.trim().to_lowercase();
74 let filtered: Vec<LogEntry> = buffer
75 .iter()
76 .filter(|e| filter.level.matches(&e.level))
77 .filter(|e| {
78 needle.is_empty()
79 || e.message.to_lowercase().contains(&needle)
80 || e.category.to_lowercase().contains(&needle)
81 || e.job_id
82 .as_deref()
83 .map(|j| j.to_lowercase().contains(&needle))
84 .unwrap_or(false)
85 })
86 .cloned()
87 .collect();
88 let total_buffer = buffer.len();
89 let windowed = filtered.len() > window;
90 let entries = if windowed {
91 filtered[filtered.len() - window..].to_vec()
92 } else {
93 filtered
94 };
95 Self {
96 entries,
97 windowed,
98 total_buffer,
99 }
100 }
101}
102
103pub fn render(ui: &mut egui::Ui, buffer: &Arc<Mutex<VecDeque<LogEntry>>>, filter: &mut LogFilter) {
104 ui.heading("Logs");
105 ui.add_space(4.0);
106 ui.horizontal(|ui| {
107 ui.label("Level:");
108 for level in [
109 LevelFilter::All,
110 LevelFilter::Info,
111 LevelFilter::Warn,
112 LevelFilter::Error,
113 ] {
114 ui.selectable_value(&mut filter.level, level, level.label());
115 }
116 ui.separator();
117 ui.label("Search:");
118 ui.add(
119 egui::TextEdit::singleline(&mut filter.search)
120 .desired_width(220.0)
121 .hint_text("category / message / job id"),
122 );
123 ui.separator();
124 ui.checkbox(&mut filter.auto_scroll, "auto-scroll");
125 });
126 ui.add_space(6.0);
127
128 let view = {
129 let buf = buffer.lock();
130 let snapshot: Vec<LogEntry> = buf.iter().cloned().collect();
132 LogsView::build(&snapshot, filter, LOGS_WINDOW)
133 };
134
135 if view.entries.is_empty() {
136 ui.label(
137 egui::RichText::new("No log entries match the current filter.")
138 .italics()
139 .color(egui::Color32::from_gray(150)),
140 );
141 return;
142 }
143
144 if view.windowed {
145 ui.label(
146 egui::RichText::new(format!(
147 "showing last {} entries (buffer holds {} total)",
148 view.entries.len(),
149 view.total_buffer
150 ))
151 .italics()
152 .color(egui::Color32::from_gray(150)),
153 );
154 }
155 ui.add_space(4.0);
156
157 let scroll = egui::ScrollArea::vertical()
158 .max_height(f32::INFINITY)
159 .stick_to_bottom(filter.auto_scroll);
160 scroll.show_rows(ui, 18.0, view.entries.len(), |ui, range| {
161 for entry in &view.entries[range] {
162 render_entry(ui, entry);
163 }
164 });
165}
166
167fn render_entry(ui: &mut egui::Ui, e: &LogEntry) {
168 let colour = match e.level.as_str() {
169 "error" => egui::Color32::LIGHT_RED,
170 "warn" => egui::Color32::from_rgb(232, 168, 56),
171 _ => egui::Color32::from_gray(200),
172 };
173 ui.horizontal(|ui| {
174 ui.monospace(
175 egui::RichText::new(&e.ts)
176 .color(egui::Color32::from_gray(120))
177 .size(11.0),
178 );
179 ui.label(
180 egui::RichText::new(format!("[{}]", e.level))
181 .color(colour)
182 .strong()
183 .size(11.0),
184 );
185 ui.label(
186 egui::RichText::new(format!("{}:", e.category))
187 .color(egui::Color32::from_gray(170))
188 .size(11.0),
189 );
190 ui.label(egui::RichText::new(&e.message).size(11.0));
191 if let Some(j) = &e.job_id {
192 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
193 ui.monospace(
194 egui::RichText::new(j)
195 .color(egui::Color32::from_gray(120))
196 .size(11.0),
197 );
198 });
199 }
200 });
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 fn entry(level: &str, category: &str, message: &str, job_id: Option<&str>) -> LogEntry {
208 LogEntry {
209 ts: "2026-05-25T10:00:00Z".into(),
210 level: level.into(),
211 category: category.into(),
212 message: message.into(),
213 job_id: job_id.map(str::to_string),
214 }
215 }
216
217 #[test]
218 fn build_with_default_filter_returns_everything() {
219 let buf = vec![
220 entry("info", "claim", "a", None),
221 entry("warn", "heartbeat", "b", None),
222 ];
223 let view = LogsView::build(&buf, &LogFilter::default(), LOGS_WINDOW);
224 assert_eq!(view.entries.len(), 2);
225 assert!(!view.windowed);
226 }
227
228 #[test]
229 fn level_filter_excludes_other_levels() {
230 let buf = vec![
231 entry("info", "x", "a", None),
232 entry("warn", "x", "b", None),
233 entry("error", "x", "c", None),
234 ];
235 let filter = LogFilter {
236 level: LevelFilter::Error,
237 ..LogFilter::default()
238 };
239 let view = LogsView::build(&buf, &filter, LOGS_WINDOW);
240 assert_eq!(view.entries.len(), 1);
241 assert_eq!(view.entries[0].level, "error");
242 }
243
244 #[test]
245 fn search_matches_message_category_or_job_id_case_insensitive() {
246 let buf = vec![
247 entry("info", "claim", "Boom and bust", None),
248 entry("info", "Boom", "noise", None),
249 entry("info", "x", "y", Some("Boom-1")),
250 entry("info", "z", "unrelated", None),
251 ];
252 let filter = LogFilter {
253 search: "boom".into(),
254 ..LogFilter::default()
255 };
256 let view = LogsView::build(&buf, &filter, LOGS_WINDOW);
257 assert_eq!(view.entries.len(), 3);
258 }
259
260 #[test]
261 fn windows_to_last_n_when_buffer_exceeds_cap() {
262 let buf: Vec<LogEntry> = (0..1000)
263 .map(|i| entry("info", "x", &format!("m{i}"), None))
264 .collect();
265 let view = LogsView::build(&buf, &LogFilter::default(), 500);
266 assert_eq!(view.entries.len(), 500);
267 assert!(view.windowed);
268 assert_eq!(view.entries.last().unwrap().message, "m999");
269 assert_eq!(view.entries.first().unwrap().message, "m500");
270 }
271
272 #[test]
273 fn auto_scroll_default_is_on() {
274 assert!(LogFilter::default().auto_scroll);
275 }
276}