Skip to main content

binocular/preview/structured_log/
filter.rs

1use crate::preview::structured_log::types::{
2    ColModal, ColumnConfig, FilterOp, LogEntry, LogFilter, LogFilterState, StructuredLog,
3};
4
5const TIMESTAMP_FIELD_NAMES: &[&str] =
6    &["time", "timestamp", "ts", "datetime", "date", "@timestamp"];
7
8impl LogFilterState {
9    pub fn recompute_matches(&mut self, log: &StructuredLog) {
10        self.cached_matches = if self.filters.is_empty() {
11            (0..log.entries.len()).rev().collect()
12        } else {
13            log.entries
14                .iter()
15                .enumerate()
16                .filter(|(_, e)| entry_matches(e, &self.filters))
17                .map(|(i, _)| i)
18                .collect::<Vec<_>>()
19                .into_iter()
20                .rev()
21                .collect()
22        };
23        self.clamp_cursor();
24    }
25
26    pub fn extend_matches(&mut self, log: &StructuredLog, from_index: usize) {
27        let mut new: Vec<usize> = (from_index..log.entries.len())
28            .filter(|&i| self.filters.is_empty() || entry_matches(&log.entries[i], &self.filters))
29            .collect();
30        new.reverse();
31        let old = std::mem::take(&mut self.cached_matches);
32        self.cached_matches = new;
33        self.cached_matches.extend(old);
34    }
35
36    pub fn apply_input(&mut self, log: &StructuredLog) {
37        self.filters = parse_filters(&self.input);
38        self.cursor = 0;
39        self.scroll = 0;
40        self.recompute_matches(log);
41    }
42
43    pub fn scroll_down(&mut self, n: usize) {
44        let max = self.cached_matches.len().saturating_sub(1);
45        self.cursor = (self.cursor + n).min(max);
46    }
47
48    pub fn scroll_up(&mut self, n: usize) {
49        self.cursor = self.cursor.saturating_sub(n);
50    }
51
52    pub fn scroll_to_bottom(&mut self) {
53        self.cursor = self.cached_matches.len().saturating_sub(1);
54    }
55
56    pub fn move_col_left(&mut self) {
57        self.selected_col = self.selected_col.saturating_sub(1);
58    }
59
60    pub fn move_col_right(&mut self) {
61        if !self.visible_cols.is_empty() {
62            self.selected_col = (self.selected_col + 1).min(self.visible_cols.len() - 1);
63        }
64    }
65
66    pub fn hide_selected_col(&mut self) {
67        if self.visible_cols.len() <= 1 {
68            return;
69        }
70        self.visible_cols.remove(self.selected_col);
71        self.selected_col = self
72            .selected_col
73            .min(self.visible_cols.len().saturating_sub(1));
74    }
75
76    pub fn isolate_selected_col(&mut self) {
77        if self.visible_cols.is_empty() {
78            return;
79        }
80        let kept = self.visible_cols.remove(self.selected_col);
81        self.visible_cols = vec![kept];
82        self.selected_col = 0;
83        self.col_scroll = 0;
84    }
85
86    pub fn resize_selected_col(&mut self, delta: i32) {
87        if let Some(col) = self.visible_cols.get_mut(self.selected_col) {
88            col.width = ((col.width as i32 + delta).max(3)) as usize;
89        }
90    }
91
92    pub fn open_col_modal(&mut self, all_fields: &[String]) {
93        let visible_set: std::collections::HashSet<&str> =
94            self.visible_cols.iter().map(|c| c.field.as_str()).collect();
95        let checked = all_fields
96            .iter()
97            .map(|f| visible_set.contains(f.as_str()))
98            .collect();
99        self.col_modal = Some(ColModal {
100            cursor: 0,
101            checked,
102            scroll: 0,
103        });
104    }
105
106    pub fn apply_modal_changes(&mut self, all_fields: &[String]) {
107        let Some(modal) = self.col_modal.take() else {
108            return;
109        };
110
111        let current_widths: std::collections::HashMap<&str, usize> = self
112            .visible_cols
113            .iter()
114            .map(|c| (c.field.as_str(), c.width))
115            .collect();
116
117        let new_cols: Vec<ColumnConfig> = all_fields
118            .iter()
119            .enumerate()
120            .filter(|(i, _)| modal.checked.get(*i).copied().unwrap_or(false))
121            .map(|(_, f)| ColumnConfig {
122                field: f.clone(),
123                width: current_widths.get(f.as_str()).copied().unwrap_or(15),
124            })
125            .collect();
126
127        if !new_cols.is_empty() {
128            self.visible_cols = new_cols;
129            self.selected_col = self
130                .selected_col
131                .min(self.visible_cols.len().saturating_sub(1));
132            self.col_scroll = self.col_scroll.min(self.selected_col);
133        }
134    }
135
136    pub fn add_new_visible_col(&mut self, field: &str) {
137        if !self.visible_cols.iter().any(|c| c.field == field) {
138            self.visible_cols.push(ColumnConfig {
139                field: field.to_string(),
140                width: 15,
141            });
142        }
143    }
144
145    pub fn toggle_mark(&mut self) {
146        let Some(&entry_idx) = self.cached_matches.get(self.cursor) else {
147            return;
148        };
149        if !self.marked.remove(&entry_idx) {
150            self.marked.insert(entry_idx);
151        }
152    }
153
154    pub fn clear_marks(&mut self) {
155        self.marked.clear();
156    }
157
158    fn clamp_cursor(&mut self) {
159        let max = self.cached_matches.len().saturating_sub(1);
160        if self.cursor > max {
161            self.cursor = max;
162        }
163        if self.scroll > self.cursor {
164            self.scroll = self.cursor;
165        }
166    }
167}
168
169fn entry_matches(entry: &LogEntry, filters: &[LogFilter]) -> bool {
170    filters.iter().all(|f| {
171        if let FilterOp::Since(cutoff) = &f.op {
172            let ts_val = entry
173                .fields
174                .iter()
175                .find(|(k, _)| {
176                    let lower = k.to_ascii_lowercase();
177                    TIMESTAMP_FIELD_NAMES.iter().any(|c| *c == lower.as_str())
178                })
179                .map(|(_, v)| v.as_str())
180                .unwrap_or("");
181            return parse_epoch_secs(ts_val)
182                .map(|e| e >= *cutoff)
183                .unwrap_or(false);
184        }
185
186        let needle = f.value.to_lowercase();
187        match &f.field {
188            None => entry
189                .fields
190                .iter()
191                .any(|(_, v)| match_value(v, &needle, &f.op)),
192            Some(fname) => {
193                let val = entry
194                    .fields
195                    .iter()
196                    .find(|(k, _)| k.eq_ignore_ascii_case(fname))
197                    .map(|(_, v)| v.as_str())
198                    .unwrap_or("");
199                match_value(val, &needle, &f.op)
200            }
201        }
202    })
203}
204
205fn match_value(haystack: &str, needle: &str, op: &FilterOp) -> bool {
206    match op {
207        FilterOp::Contains => haystack.to_lowercase().contains(needle),
208        FilterOp::Equals => haystack.eq_ignore_ascii_case(needle),
209        FilterOp::NotEquals => !haystack.eq_ignore_ascii_case(needle),
210        FilterOp::Since(_) => true,
211    }
212}
213
214pub fn parse_filters(input: &str) -> Vec<LogFilter> {
215    input
216        .split_whitespace()
217        .filter_map(|tok| {
218            if let Some(arg) = tok.strip_prefix("last:") {
219                return parse_time_cutoff(arg).map(|cutoff| LogFilter {
220                    field: None,
221                    op: FilterOp::Since(cutoff),
222                    value: tok.to_string(),
223                });
224            }
225
226            Some(if let Some(idx) = tok.find("!=") {
227                LogFilter {
228                    field: Some(tok[..idx].to_string()),
229                    op: FilterOp::NotEquals,
230                    value: tok[idx + 2..].to_string(),
231                }
232            } else if let Some(idx) = tok.find('=') {
233                LogFilter {
234                    field: Some(tok[..idx].to_string()),
235                    op: FilterOp::Equals,
236                    value: tok[idx + 1..].to_string(),
237                }
238            } else if let Some(idx) = tok.find(':') {
239                if idx > 0 {
240                    LogFilter {
241                        field: Some(tok[..idx].to_string()),
242                        op: FilterOp::Contains,
243                        value: tok[idx + 1..].to_string(),
244                    }
245                } else {
246                    LogFilter {
247                        field: None,
248                        op: FilterOp::Contains,
249                        value: tok[1..].to_string(),
250                    }
251                }
252            } else {
253                LogFilter {
254                    field: None,
255                    op: FilterOp::Contains,
256                    value: tok.to_string(),
257                }
258            })
259        })
260        .collect()
261}
262
263pub fn parse_epoch_secs(val: &str) -> Option<u64> {
264    let trimmed = val.trim();
265    if let Ok(n) = trimmed.parse::<u64>() {
266        return Some(if n > 10_000_000_000 { n / 1000 } else { n });
267    }
268    if let Ok(f) = trimmed.parse::<f64>() {
269        if f > 0.0 {
270            return Some(if f > 1e10 {
271                (f / 1000.0) as u64
272            } else {
273                f as u64
274            });
275        }
276    }
277    parse_datetime_to_epoch(trimmed)
278}
279
280fn parse_time_cutoff(arg: &str) -> Option<u64> {
281    let (num_str, secs_per_unit) = if let Some(n) = arg.strip_suffix('h') {
282        (n, 3600u64)
283    } else if let Some(n) = arg.strip_suffix('m') {
284        (n, 60u64)
285    } else if let Some(n) = arg.strip_suffix('d') {
286        (n, 86_400u64)
287    } else if let Some(n) = arg.strip_suffix('s') {
288        (n, 1u64)
289    } else {
290        return None;
291    };
292    let n: u64 = num_str.parse().ok()?;
293    let now = std::time::SystemTime::now()
294        .duration_since(std::time::UNIX_EPOCH)
295        .map(|d| d.as_secs())
296        .unwrap_or(0);
297    Some(now.saturating_sub(n * secs_per_unit))
298}
299
300fn parse_datetime_to_epoch(s: &str) -> Option<u64> {
301    let s = s.trim_end_matches('Z');
302    let sep = s.find('T').or_else(|| s.find(' '))?;
303    let date_str = &s[..sep];
304    let time_str = &s[sep + 1..];
305
306    let mut dp = date_str.split('-');
307    let year: i64 = dp.next()?.parse().ok()?;
308    let month: i64 = dp.next()?.parse().ok()?;
309    let day: i64 = dp.next()?.parse().ok()?;
310
311    let time_str = time_str.split('+').next().unwrap_or(time_str);
312    let time_str = if time_str.len() > 8 {
313        if let Some(pos) = time_str[8..].find('-') {
314            &time_str[..8 + pos]
315        } else {
316            time_str
317        }
318    } else {
319        time_str
320    };
321
322    let mut tp = time_str.split(':');
323    let hour: i64 = tp.next()?.parse().ok()?;
324    let min: i64 = tp.next().and_then(|s| s.parse().ok()).unwrap_or(0);
325    let sec: i64 = tp
326        .next()
327        .and_then(|s| s.split('.').next())
328        .and_then(|s| s.parse().ok())
329        .unwrap_or(0);
330
331    let epoch_days = days_from_civil(year, month, day);
332    let epoch_secs = epoch_days * 86400 + hour * 3600 + min * 60 + sec;
333    if epoch_secs >= 0 {
334        Some(epoch_secs as u64)
335    } else {
336        None
337    }
338}
339
340fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
341    let y = if m <= 2 { y - 1 } else { y };
342    let era = (if y >= 0 { y } else { y - 399 }) / 400;
343    let yoe = y - era * 400;
344    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
345    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
346    era * 146097 + doe - 719468
347}