binocular/preview/structured_log/
filter.rs1use 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}