Skip to main content

studio_worker/ui/tabs/
logs.rs

1//! Logs tab — windowed view over the bounded ring the runtime keeps
2//! in `WorkerObservers::recent_logs`.  Separate from the shipping
3//! queue (which is drained every WS tick); reading from the ring
4//! means the display doesn't blank out between ships.
5
6use 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/// Snapshot of the filtered log window the renderer iterates over.
62#[derive(Debug, Clone, PartialEq)]
63pub struct LogsView {
64    pub entries: Vec<LogEntry>,
65    /// True when the underlying buffer is longer than the window —
66    /// surfaces "showing last N of M" hint in the UI.
67    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        // VecDeque doesn't slice directly; copy the (bounded) snapshot.
131        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}