1mod source;
17
18use std::time::Instant;
19
20use yaml_rust2::Yaml;
21
22use crate::term::{self, Key, Row, Term};
23use mira_core::json::Json;
24
25pub use source::{Source, parse_addr};
26
27const WINDOWS: [&str; 7] = ["5m", "15m", "1h", "6h", "24h", "7d", "30d"];
30
31pub fn run(src: Source) -> Result<(), String> {
32 let mut app = App::new(src);
33 let mut term = Term::enter().map_err(|e| e.to_string())?;
34 loop {
35 let (w, h) = term.size();
36 term.draw(&app.frame(w, h)).map_err(|e| e.to_string())?;
37
38 if let Some(job) = app.job.take() {
41 app.run(job, h);
42 continue;
43 }
44 match term
48 .key(if app.tail { TAIL_MS } else { -1 })
49 .map_err(|e| e.to_string())?
50 {
51 Some(k) if !app.key(k, h) => return Ok(()),
52 None if app.tail => app.job = Some(Job::Rows),
56 _ => {}
57 }
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62enum Tab {
63 Logs,
64 Traces,
65 Metrics,
66}
67
68impl Tab {
69 fn signal(self) -> &'static str {
70 match self {
71 Tab::Traces => "traces",
72 _ => "logs",
73 }
74 }
75
76 fn free_text(self) -> Option<&'static str> {
87 match self {
88 Tab::Logs => Some("body"),
89 Tab::Traces => Some("name"),
90 Tab::Metrics => None,
91 }
92 }
93
94 fn fields(self) -> &'static [&'static str] {
105 match self {
106 Tab::Traces => &[
107 "trace_id",
108 "span_id",
109 "parent_span_id",
110 "trace_state",
111 "flags",
112 "name",
113 "kind",
114 "start_time_unix_nano",
115 "duration_nano",
116 "status_code",
117 "status_message",
118 "dropped_attributes_count",
119 "dropped_events_count",
120 "dropped_links_count",
121 ],
122 _ => &[
123 "time_unix_nano",
124 "observed_time_unix_nano",
125 "severity_number",
126 "severity_text",
127 "event_name",
128 "body",
129 "trace_id",
130 "span_id",
131 "flags",
132 "dropped_attributes_count",
133 ],
134 }
135 }
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139enum Mode {
140 List,
141 Filter,
142 Detail,
143 Trace,
144 Span,
150 Frame,
152 Map,
154 Alerts,
156 Diag,
158 Help,
159}
160
161impl Mode {
162 fn scrolls(self) -> bool {
164 matches!(self, Mode::Detail | Mode::Span | Mode::Help | Mode::Diag)
165 }
166}
167
168enum Job {
169 Rows,
170 Names,
171 Series,
172 Trace(String),
173 Frame,
174 Map,
175 Alerts,
176 Diag,
177}
178
179enum Pick {
181 Service(String),
182 Trace(String),
183}
184
185const ENTRY_KEY: &str = "entry";
189
190struct App {
191 src: Source,
192 tab: Tab,
193 win: usize,
194 limit: usize,
195 filter: String,
196 filter_undo: String,
198 mode: Mode,
199
200 rows: Vec<Yaml>,
201 sel: usize,
202
203 names: Vec<Yaml>,
204 nsel: usize,
205 series: Vec<Yaml>,
206 ssel: usize,
207 on_series: bool,
209
210 trace: Option<Trace>,
211 frame: Option<FrameView>,
214 map: Option<MapView>,
215 alerts: Vec<Yaml>,
218 diag: Option<Yaml>,
221 psel: usize,
222 tail: bool,
224 scroll: usize,
225 stats: String,
226 status: String,
227 err: bool,
228 job: Option<Job>,
229}
230
231const TAIL_MS: i32 = 3000;
237
238struct FrameView {
244 from: i64,
245 to: i64,
246 truncated: bool,
247 services: Vec<(String, usize)>,
248 traces: Vec<String>,
249}
250
251struct MapView {
258 rows: Vec<(usize, Yaml)>,
259 unresolved: i64,
260}
261
262struct Trace {
263 id: String,
264 spans: Vec<(Yaml, usize)>,
267 t0: i64,
268 span_ns: i64,
269 sel: usize,
270}
271
272impl App {
273 fn new(src: Source) -> App {
274 App {
275 src,
276 tab: Tab::Logs,
277 win: 2,
278 limit: 200,
279 filter: String::new(),
280 filter_undo: String::new(),
281 mode: Mode::List,
282 rows: Vec::new(),
283 sel: 0,
284 names: Vec::new(),
285 nsel: 0,
286 series: Vec::new(),
287 ssel: 0,
288 on_series: false,
289 trace: None,
290 frame: None,
291 map: None,
292 alerts: Vec::new(),
293 diag: None,
294 psel: 0,
295 tail: false,
296 scroll: 0,
297 stats: String::new(),
298 status: "loading".into(),
299 err: false,
300 job: Some(Job::Rows),
301 }
302 }
303
304 fn reload(&mut self) {
307 self.status = "running".into();
308 self.err = false;
309 self.job = Some(match self.tab {
310 Tab::Metrics => Job::Names,
311 _ => Job::Rows,
312 });
313 }
314
315 fn run(&mut self, job: Job, h: usize) {
316 let t = Instant::now();
317 let r = match &job {
318 Job::Rows => self.src.post(source::QUERY, &self.rows_query()),
319 Job::Names => self.src.post(source::NAMES, &self.window_query()),
320 Job::Series => self.src.post(source::SERIES, &self.series_query()),
321 Job::Trace(id) => self.src.post(source::QUERY, &trace_query(id)),
322 Job::Frame => self.src.post(source::CORRELATE, &self.frame_query()),
323 Job::Map => self.src.post(source::MAP, &self.window_query()),
324 Job::Alerts => self.src.get(source::ALERTS),
325 Job::Diag => self.src.get(source::STATS),
326 };
327 let doc = match r {
328 Ok(d) => d,
329 Err(e) => {
330 self.status = e;
331 self.err = true;
332 return;
333 }
334 };
335 self.err = false;
336 self.stats = match job {
337 Job::Alerts | Job::Diag => ms(t.elapsed()),
340 _ => format!("{} · {}", stats_line(&doc["stats"]), ms(t.elapsed())),
341 };
342 self.status.clear();
343
344 if let Some(w) = self.ignored_word() {
345 self.status = format!("ignored {w:?}: metrics filters are attr=value terms");
346 }
347
348 match job {
349 Job::Rows => {
350 self.rows = array(&doc["rows"]);
351 match self.tail {
355 true => self.sel = self.sel.min(self.rows.len().saturating_sub(1)),
356 false => {
357 self.sel = 0;
358 self.scroll = 0;
359 }
360 }
361 if self.rows.is_empty() {
362 self.status = "no rows in this window".into();
363 }
364 }
365 Job::Names => {
366 self.names = array(&doc["names"]);
367 self.nsel = 0;
368 self.on_series = false;
369 match self.names.is_empty() {
370 true => self.status = "no metrics in this window".into(),
371 false => self.job = Some(Job::Series),
374 }
375 }
376 Job::Series => {
377 self.series = array(&doc["series"]);
378 self.ssel = 0;
379 self.scroll = 0;
380 }
381 Job::Trace(id) => {
382 let spans = array(&doc["rows"]);
383 if spans.is_empty() {
384 self.status = format!("no spans found for trace {id}");
385 self.err = true;
386 return;
387 }
388 self.trace = Some(Trace::new(id, &spans));
389 self.mode = Mode::Trace;
390 self.scroll = 0;
391 let _ = h;
392 }
393 Job::Frame => {
394 let f = FrameView::new(&doc["frame"]);
395 if f.services.is_empty() && f.traces.is_empty() {
396 self.status = "nothing matched, so there is no frame to widen".into();
397 return;
398 }
399 self.frame = Some(f);
400 self.mode = Mode::Frame;
401 self.psel = 0;
402 }
403 Job::Alerts => {
404 self.alerts = array(&doc["alerts"]);
405 self.mode = Mode::Alerts;
406 self.psel = 0;
407 if self.alerts.is_empty() {
408 self.status =
412 "this node evaluates no rules — set alerts.rules in mira.yaml".into();
413 }
414 }
415 Job::Diag => {
416 self.diag = Some(doc);
417 self.mode = Mode::Diag;
418 self.scroll = 0;
419 }
420 Job::Map => {
421 let m = MapView::new(&doc["map"]);
422 if m.rows.is_empty() {
423 self.status = "no spans in this window, so there is no map".into();
427 return;
428 }
429 self.map = Some(m);
430 self.mode = Mode::Map;
431 self.psel = 0;
432 }
433 }
434 }
435
436 fn rows_query(&self) -> String {
437 let mut j = Json::new();
438 j.obj(|j| {
439 j.key("signal");
440 j.str(self.tab.signal());
441 j.key("from");
442 j.str(&format!("-{}", WINDOWS[self.win]));
443 j.key("to");
444 j.str("now");
445 j.key("limit");
446 j.i64(self.limit as i64);
447 j.key("where");
448 self.terms(j);
449 });
450 j.into_string()
451 }
452
453 fn frame_query(&self) -> String {
464 let mut j = Json::new();
465 j.obj(|j| {
466 j.key("signal");
467 j.str(self.tab.signal());
468 j.key("from");
469 j.str(&format!("-{}", WINDOWS[self.win]));
470 j.key("to");
471 j.str("now");
472 j.key("expand");
473 j.arr(|j| {
474 j.str("traces");
475 j.str("peers");
476 });
477 j.key("where");
478 self.terms(j);
479 });
480 j.into_string()
481 }
482
483 fn window_query(&self) -> String {
484 let mut j = Json::new();
485 j.obj(|j| {
486 j.key("from");
487 j.str(&format!("-{}", WINDOWS[self.win]));
488 j.key("to");
489 j.str("now");
490 });
491 j.into_string()
492 }
493
494 fn series_query(&self) -> String {
495 let name = self
496 .names
497 .get(self.nsel)
498 .and_then(|n| n["name"].as_str())
499 .unwrap_or_default()
500 .to_owned();
501 let mut j = Json::new();
502 j.obj(|j| {
503 j.key("name");
504 j.str(&name);
505 j.key("from");
506 j.str(&format!("-{}", WINDOWS[self.win]));
507 j.key("to");
508 j.str("now");
509 j.key("max_series");
514 j.i64(64);
515 j.key("max_points");
523 j.i64(400);
524 j.key("where");
525 self.terms(j);
526 });
527 j.into_string()
528 }
529
530 fn ignored_word(&self) -> Option<String> {
536 if self.tab.free_text().is_some() {
537 return None;
538 }
539 parse_filter(&self.filter)
540 .into_iter()
541 .find_map(|p| match p {
542 Part::Word(w) => Some(w),
543 Part::Term(..) => None,
544 })
545 }
546
547 fn terms(&self, j: &mut Json) {
548 j.arr(|j| {
549 for part in parse_filter(&self.filter) {
550 let (key, op, val) = match part {
551 Part::Term(k, op, v) => (k, op, v),
552 Part::Word(w) => match self.tab.free_text() {
556 Some(f) => {
557 j.obj(|j| {
558 j.key("field");
559 j.str(f);
560 j.key("contains");
561 j.str(&w);
562 });
563 continue;
564 }
565 None => continue,
566 },
567 };
568 let field = self.tab.fields().contains(&key.as_str());
569 j.obj(|j| {
570 j.key(if field { "field" } else { "attr" });
571 j.str(&key);
572 j.key(op);
573 scalar(j, &key, &val);
574 });
575 }
576 });
577 }
578
579 fn key(&mut self, k: Key, h: usize) -> bool {
583 if self.mode == Mode::Filter {
584 return self.filter_key(k);
585 }
586 let page = body_h(h).saturating_sub(1).max(1);
587 match k {
588 Key::Char('q') | Key::Ctrl('c') if self.mode == Mode::List => return false,
589 Key::Char('q') | Key::Esc => {
592 self.mode = match self.mode {
593 Mode::Span => Mode::Trace,
594 _ => Mode::List,
595 }
596 }
597 Key::Ctrl('c') => return false,
598 Key::Char('?') => {
599 self.mode = match self.mode {
600 Mode::Help => Mode::List,
601 _ => Mode::Help,
602 }
603 }
604 Key::Char('1') => self.go(Tab::Logs),
605 Key::Char('2') => self.go(Tab::Traces),
606 Key::Char('3') => self.go(Tab::Metrics),
607 Key::Char('h') | Key::Left => self.go(match self.tab {
608 Tab::Logs => Tab::Metrics,
609 Tab::Traces => Tab::Logs,
610 Tab::Metrics => Tab::Traces,
611 }),
612 Key::Char('l') | Key::Right => self.go(match self.tab {
613 Tab::Logs => Tab::Traces,
614 Tab::Traces => Tab::Metrics,
615 Tab::Metrics => Tab::Logs,
616 }),
617 Key::Tab if self.tab == Tab::Metrics && self.mode == Mode::List => {
618 self.on_series = !self.on_series;
619 }
620 Key::Char('j') | Key::Down => self.move_by(1),
621 Key::Char('k') | Key::Up => self.move_by(-1),
622 Key::PageDown | Key::Ctrl('f') => self.move_by(page as isize),
623 Key::PageUp | Key::Ctrl('b') => self.move_by(-(page as isize)),
624 Key::Home | Key::Char('g') => self.move_to(0),
625 Key::End | Key::Char('G') => self.move_to(usize::MAX),
626 Key::Char('/') if self.mode == Mode::List => {
627 self.filter_undo = self.filter.clone();
628 self.mode = Mode::Filter;
629 }
630 Key::Char('r') => self.reload(),
631 Key::Char('[') => {
632 self.win = self.win.saturating_sub(1);
633 self.reload();
634 }
635 Key::Char(']') => {
636 self.win = (self.win + 1).min(WINDOWS.len() - 1);
637 self.reload();
638 }
639 Key::Char('+') | Key::Char('=') => {
640 self.limit = (self.limit * 2).min(10_000);
641 self.reload();
642 }
643 Key::Char('-') => {
644 self.limit = (self.limit / 2).max(10);
645 self.reload();
646 }
647 Key::Enter => match self.mode {
648 Mode::List if self.tab == Tab::Metrics && !self.on_series => {
649 self.job = Some(Job::Series);
650 self.status = "running".into();
651 }
652 Mode::List => {
653 self.mode = Mode::Detail;
654 self.scroll = 0;
655 }
656 Mode::Trace => {
660 self.mode = Mode::Span;
661 self.scroll = 0;
662 }
663 Mode::Frame | Mode::Map => self.follow_pick(),
664 Mode::Alerts => self.follow_alert(),
665 _ => {}
666 },
667 Key::Char('t') => self.open_trace(),
668 Key::Char('c') if self.mode == Mode::List => match self.tab {
669 Tab::Metrics => {
670 self.status = "correlate anchors on logs or traces; switch tab first".into();
671 self.err = true;
672 }
673 _ => {
674 self.status = "running".into();
675 self.job = Some(Job::Frame);
676 }
677 },
678 Key::Char('m') if self.mode == Mode::List => {
679 self.status = "running".into();
680 self.job = Some(Job::Map);
681 }
682 Key::Char('a') => {
686 self.status = "running".into();
687 self.job = Some(Job::Alerts);
688 }
689 Key::Char('d') => {
690 self.status = "running".into();
691 self.job = Some(Job::Diag);
692 }
693 Key::Char('f') => match (self.mode, self.tab) {
697 (Mode::List, Tab::Logs | Tab::Traces) => {
698 self.tail = !self.tail;
699 if self.tail {
700 self.reload();
701 }
702 }
703 _ => {
704 self.status = "follow needs the logs or traces list".into();
705 self.err = true;
706 }
707 },
708 _ => {}
709 }
710 true
711 }
712
713 fn follow_pick(&mut self) {
719 match self.pick() {
720 Some(Pick::Trace(id)) => {
721 self.status = format!("loading trace {id}");
722 self.job = Some(Job::Trace(id));
723 }
724 Some(Pick::Service(name)) => {
728 let t = service_term(&name);
729 if !self.filter.contains(&t) {
732 if !self.filter.is_empty() {
733 self.filter.push(' ');
734 }
735 self.filter.push_str(&t);
736 }
737 self.mode = Mode::List;
738 self.reload();
739 }
740 None => {}
741 }
742 }
743
744 fn follow_alert(&mut self) {
753 let Some(a) = self.alerts.get(self.psel) else {
754 return;
755 };
756 self.tab = match a["signal"].as_str() {
757 Some("traces") => Tab::Traces,
758 _ => Tab::Logs,
759 };
760 self.filter = a["filter"].as_str().unwrap_or_default().to_owned();
761 self.mode = Mode::List;
762 self.reload();
763 }
764
765 fn pick(&self) -> Option<Pick> {
767 match self.mode {
768 Mode::Frame => {
769 let f = self.frame.as_ref()?;
770 match f.services.get(self.psel) {
771 Some((n, _)) => Some(Pick::Service(n.clone())),
772 None => f
773 .traces
774 .get(self.psel - f.services.len())
775 .map(|t| Pick::Trace(t.clone())),
776 }
777 }
778 Mode::Map => {
779 let (_, n) = self.map.as_ref()?.rows.get(self.psel)?;
780 match n["key"].as_str() {
783 Some(ENTRY_KEY) => None,
784 _ => Some(Pick::Service(n["name"].as_str()?.to_owned())),
785 }
786 }
787 _ => None,
788 }
789 }
790
791 fn filter_key(&mut self, k: Key) -> bool {
792 match k {
793 Key::Enter => {
794 self.mode = Mode::List;
795 self.reload();
796 }
797 Key::Esc | Key::Ctrl('c') => {
798 self.filter = std::mem::take(&mut self.filter_undo);
799 self.mode = Mode::List;
800 }
801 Key::Backspace => {
802 self.filter.pop();
803 }
804 Key::Ctrl('u') => self.filter.clear(),
805 Key::Ctrl('w') => {
806 let keep = self.filter.trim_end();
807 let cut = keep.rfind(' ').map_or(0, |i| i + 1);
808 self.filter.truncate(cut);
809 }
810 Key::Char(c) => self.filter.push(c),
811 _ => {}
812 }
813 true
814 }
815
816 fn go(&mut self, tab: Tab) {
817 if self.tab != tab || self.mode != Mode::List {
818 self.tab = tab;
819 self.mode = Mode::List;
820 self.reload();
821 }
822 }
823
824 fn move_by(&mut self, d: isize) {
835 let (sel, len) = self.cursor();
836 self.set_cursor(sel.saturating_add_signed(d).min(len.saturating_sub(1)));
837 }
838
839 fn move_to(&mut self, n: usize) {
840 let (_, len) = self.cursor();
841 self.set_cursor(n.min(len.saturating_sub(1)));
842 }
843
844 fn cursor(&self) -> (usize, usize) {
845 match (self.mode, self.tab) {
846 (m, _) if m.scrolls() => (self.scroll, usize::MAX),
851 (Mode::Trace, _) => (
852 self.trace.as_ref().map_or(0, |t| t.sel),
853 self.trace.as_ref().map_or(0, |t| t.spans.len()),
854 ),
855 (Mode::Frame, _) => (self.psel, self.frame.as_ref().map_or(0, FrameView::len)),
856 (Mode::Map, _) => (self.psel, self.map.as_ref().map_or(0, |m| m.rows.len())),
857 (Mode::Alerts, _) => (self.psel, self.alerts.len()),
858 (_, Tab::Metrics) if self.on_series => (self.ssel, self.series.len()),
859 (_, Tab::Metrics) => (self.nsel, self.names.len()),
860 _ => (self.sel, self.rows.len()),
861 }
862 }
863
864 fn set_cursor(&mut self, n: usize) {
865 match (self.mode, self.tab) {
866 (m, _) if m.scrolls() => self.scroll = n,
867 (Mode::Trace, _) => {
868 if let Some(t) = self.trace.as_mut() {
869 t.sel = n;
870 }
871 }
872 (Mode::Frame | Mode::Map | Mode::Alerts, _) => self.psel = n,
873 (_, Tab::Metrics) if self.on_series => self.ssel = n,
874 (_, Tab::Metrics) => self.nsel = n,
875 _ => self.sel = n,
876 }
877 }
878
879 fn open_trace(&mut self) {
887 let id = match (self.mode, self.tab) {
888 (Mode::Trace | Mode::Span, _) => return,
890 (Mode::Frame | Mode::Map, _) => match self.pick() {
894 Some(Pick::Trace(id)) => Some(id),
895 _ => None,
896 },
897 (_, Tab::Metrics) => self
898 .series
899 .get(self.ssel)
900 .and_then(|s| s["exemplars"][0]["trace_id"].as_str())
901 .map(str::to_owned),
902 _ => self
903 .rows
904 .get(self.sel)
905 .and_then(|r| r["trace_id"].as_str())
906 .map(str::to_owned),
907 };
908 match id.filter(|s| s.len() == 32 && s.bytes().any(|b| b != b'0')) {
909 Some(id) => {
910 self.status = format!("loading trace {id}");
911 self.job = Some(Job::Trace(id));
912 }
913 None => {
914 self.status = "nothing here carries a trace id".into();
915 self.err = true;
916 }
917 }
918 }
919
920 fn selected(&self) -> Option<&Yaml> {
921 match (self.mode, self.tab) {
922 (Mode::Span, _) => self
926 .trace
927 .as_ref()
928 .and_then(|t| t.spans.get(t.sel))
929 .map(|(s, _)| s),
930 (_, Tab::Metrics) => self.series.get(self.ssel),
931 _ => self.rows.get(self.sel),
932 }
933 }
934
935 fn frame(&mut self, w: usize, h: usize) -> Vec<String> {
938 if w < 40 || h < 8 {
944 let mut r = Row::new(w);
945 r.put(term::RED, "terminal too small — need 40x8");
946 return vec![r.done()];
947 }
948 let mut out = Vec::with_capacity(h);
949 out.push(self.tabbar(w));
950 out.push(self.filterbar(w));
951
952 let bh = body_h(h);
953 let mut body = match self.mode {
954 Mode::Help => help(w),
955 Mode::Detail | Mode::Span => self.detail_full(w),
956 Mode::Trace => self.waterfall(w, bh),
957 Mode::Frame => self.frame_pane(w, bh),
958 Mode::Map => self.map_pane(w, bh),
959 Mode::Alerts => self.alerts_pane(w, bh),
960 Mode::Diag => self.diag_pane(w),
961 _ => match self.tab {
962 Tab::Metrics => self.metrics(w, bh),
963 _ => self.records(w, bh),
964 },
965 };
966 if self.mode.scrolls() {
969 self.scroll = self.scroll.min(body.len().saturating_sub(1));
970 body = body.into_iter().skip(self.scroll).take(bh).collect();
971 }
972 body.truncate(bh);
973 while body.len() < bh {
974 body.push(String::new());
975 }
976 out.extend(body);
977 out.push(self.statusbar(w));
978 out.push(self.hints(w));
979 out
980 }
981
982 fn tabbar(&self, w: usize) -> String {
983 let mut r = Row::new(w);
984 r.put(term::BOLD, " mira ");
985 for (i, (n, t)) in [
986 ("1 logs", Tab::Logs),
987 ("2 traces", Tab::Traces),
988 ("3 metrics", Tab::Metrics),
989 ]
990 .iter()
991 .enumerate()
992 {
993 r.plain(if i == 0 { " " } else { " " });
994 match *t == self.tab {
995 true => r.put(term::REV, &format!(" {n} ")),
996 false => r.put(term::DIM, &format!(" {n} ")),
997 };
998 }
999 let label = self.src.label();
1000 r.pad_to(w.saturating_sub(label.len() + 1));
1001 r.put(term::DIM, &label).plain(" ");
1002 r.done()
1003 }
1004
1005 fn filterbar(&self, w: usize) -> String {
1006 let right = format!(
1010 "{}last {} limit {}",
1011 if self.tail { "● follow " } else { "" },
1012 WINDOWS[self.win],
1013 self.limit
1014 );
1015 let keep = w.saturating_sub(right.len() + 2);
1016 let mut r = Row::new(keep);
1017 let editing = self.mode == Mode::Filter;
1018 r.put(
1019 if editing { term::BOLD } else { term::DIM },
1020 if editing { " filter> " } else { " filter " },
1021 );
1022 match (self.filter.is_empty(), editing) {
1023 (true, false) => {
1024 r.put(
1025 term::DIM,
1026 "(none) — press / to add one, e.g. service.name=checkout",
1027 );
1028 }
1029 _ => {
1030 r.plain(&self.filter);
1031 if editing {
1032 r.put(term::REV, " ");
1036 }
1037 }
1038 }
1039 r.cap(w).pad_to(w.saturating_sub(right.len() + 1));
1040 r.put(term::DIM, &right).plain(" ");
1041 r.done()
1042 }
1043
1044 fn statusbar(&self, w: usize) -> String {
1045 let mut r = Row::new(w);
1046 r.plain(" ");
1047 match self.status.is_empty() {
1048 false => {
1049 r.put(
1050 if self.err { term::RED } else { term::YELLOW },
1051 &self.status,
1052 );
1053 }
1054 true => {
1055 r.put(term::DIM, &self.stats);
1056 }
1057 }
1058 if !self.status.is_empty() && !self.stats.is_empty() {
1059 r.plain(" ");
1060 r.put(term::DIM, &self.stats);
1061 }
1062 r.done()
1063 }
1064
1065 fn hints(&self, w: usize) -> String {
1066 let keys = match self.mode {
1067 Mode::Filter => "enter apply esc cancel ^w word ^u clear",
1068 Mode::Detail => "esc back ↑↓ scroll t trace",
1069 Mode::Trace => "esc back ↑↓ span enter detail",
1070 Mode::Span => "esc waterfall ↑↓ scroll",
1071 Mode::Frame => "esc back ↑↓ move enter service→filter, trace→waterfall",
1072 Mode::Map => "esc back ↑↓ move enter filter on this service",
1073 Mode::Alerts => "esc back ↑↓ move enter show the records that fired a reload",
1074 Mode::Diag => "esc back ↑↓ scroll d reload",
1075 Mode::Help => "esc back",
1076 Mode::List if self.tab == Tab::Metrics => {
1077 "↑↓ move tab pane enter load t trace m map a alerts d node / filter [] window ? help"
1078 }
1079 Mode::List => {
1080 "↑↓ move enter detail t trace c frame m map a alerts d node f follow / filter ? help"
1081 }
1082 };
1083 let mut r = Row::new(w);
1084 r.put(term::DIM, &format!(" {keys}"));
1085 r.done()
1086 }
1087
1088 fn records(&self, w: usize, h: usize) -> Vec<String> {
1090 let list_h = (h * 2 / 3).max(1);
1093 let top = window_start(self.sel, list_h, self.rows.len());
1094 let scale = match self.tab {
1095 Tab::Traces => self
1096 .rows
1097 .iter()
1098 .map(|r| i64_of(&r["duration_nano"]))
1099 .max()
1100 .unwrap_or(1)
1101 .max(1),
1102 _ => 1,
1103 };
1104
1105 let mut out: Vec<String> = self
1106 .rows
1107 .iter()
1108 .enumerate()
1109 .skip(top)
1110 .take(list_h)
1111 .map(|(i, row)| match self.tab {
1112 Tab::Traces => span_row(row, w, i == self.sel, scale),
1113 _ => log_row(row, w, i == self.sel),
1114 })
1115 .collect();
1116 while out.len() < list_h {
1117 out.push(String::new());
1118 }
1119
1120 let title = match self.selected() {
1121 Some(_) => format!(
1122 " {} {} of {} ",
1123 match self.tab {
1124 Tab::Traces => "span",
1125 _ => "record",
1126 },
1127 self.sel + 1,
1128 self.rows.len()
1129 ),
1130 None => " nothing selected ".into(),
1131 };
1132 out.push(rule(w, &title));
1133 let left = h - out.len();
1134 if let Some(row) = self.selected() {
1135 out.extend(detail(row, w).into_iter().take(left));
1136 }
1137 out
1138 }
1139
1140 fn detail_full(&self, w: usize) -> Vec<String> {
1141 match self.selected() {
1142 Some(row) => detail(row, w),
1143 None => {
1144 let mut r = Row::new(w);
1145 r.put(term::DIM, " nothing selected");
1146 vec![r.done()]
1147 }
1148 }
1149 }
1150
1151 fn metrics(&self, w: usize, h: usize) -> Vec<String> {
1153 let nw = 34.min(w / 3);
1154 let top = window_start(self.nsel, h, self.names.len());
1155 let mut out = Vec::with_capacity(h);
1156
1157 let chart_w = w.saturating_sub(nw + 3);
1158 let right = self.series_lines(chart_w);
1159 let rtop = window_start(self.ssel * 3, h, right.len());
1160
1161 for i in 0..h {
1162 let mut r = Row::new(w);
1163 if let Some(n) = self.names.get(top + i) {
1164 let sel = top + i == self.nsel;
1165 let style = match (sel, self.on_series) {
1166 (true, false) => term::REV,
1167 (true, true) => term::BOLD,
1168 _ => "",
1169 };
1170 let name = clip(n["name"].as_str().unwrap_or("?"), nw.saturating_sub(9));
1171 r.put(style, &format!(" {name}"));
1172 r.pad_to(nw.saturating_sub(8));
1173 r.put(term::DIM, &clip(n["kind"].as_str().unwrap_or(""), 8));
1174 }
1175 r.pad_to(nw);
1176 r.put(term::DIM, " │ ");
1177 if let Some(line) = right.get(rtop + i) {
1178 r.raw(line, chart_w);
1180 }
1181 out.push(r.done());
1182 }
1183 out
1184 }
1185
1186 fn series_lines(&self, w: usize) -> Vec<String> {
1191 if self.series.is_empty() {
1192 let mut r = Row::new(w);
1195 if !self.names.is_empty() {
1196 r.put(term::DIM, " enter to load this metric");
1197 }
1198 return vec![r.done()];
1199 }
1200 let mut out = Vec::with_capacity(self.series.len() * 3);
1201 for (i, s) in self.series.iter().enumerate() {
1202 let sel = self.on_series && i == self.ssel;
1203 let mark = if sel { "▌" } else { " " };
1204 let attrs = pairs(&s["attributes"])
1205 .iter()
1206 .filter(|(k, _)| !k.starts_with("otel.scope."))
1207 .map(|(k, v)| format!("{k}={v}"))
1208 .collect::<Vec<_>>()
1209 .join(" ");
1210 let mut r = Row::new(w);
1211 r.plain(mark);
1212 r.put(if sel { term::BOLD } else { "" }, &attrs);
1213 out.push(r.done());
1214
1215 let pts: Vec<f64> = s["points"]
1216 .as_vec()
1217 .map(|v| v.iter().filter_map(|p| num(&p[1])).collect())
1218 .unwrap_or_default();
1219 let mut r = Row::new(w);
1220 r.plain(mark);
1221 match pts.is_empty() {
1222 true => {
1223 r.put(term::DIM, "no points");
1224 }
1225 false => {
1226 let (lo, hi) = (
1227 pts.iter().cloned().fold(f64::INFINITY, f64::min),
1228 pts.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
1229 );
1230 let badge = match s["dropped_points"].as_i64().unwrap_or(0) {
1238 0 => String::new(),
1239 n => format!(" +{n} dropped"),
1240 };
1241 let bars = w.saturating_sub(28 + badge.len()).max(8);
1242 r.put(term::CYAN, &spark(&pts, bars));
1243 r.plain(" ");
1244 r.put(term::DIM, &format!("{} → {}", g(lo), g(hi)));
1245 if !badge.is_empty() {
1246 r.put(term::YELLOW, &badge);
1247 }
1248 }
1249 }
1250 let ex = s["exemplars"].as_vec().map_or(0, Vec::len);
1251 if ex > 0 {
1252 r.plain(" ");
1253 r.put(term::MAGENTA, &format!("◆{ex}"));
1254 }
1255 out.push(r.done());
1256
1257 let mut r = Row::new(w);
1258 r.plain(mark);
1259 if let Some(id) = s["exemplars"][0]["trace_id"].as_str() {
1260 r.put(term::MAGENTA, "◆ ");
1261 r.put(term::DIM, "trace ");
1262 r.plain(&clip(id, 32));
1263 r.put(term::DIM, " t to open");
1264 }
1265 out.push(r.done());
1266 }
1267 out
1268 }
1269
1270 fn frame_pane(&self, w: usize, h: usize) -> Vec<String> {
1272 let Some(f) = &self.frame else {
1273 return Vec::new();
1274 };
1275 let mut out = Vec::with_capacity(h);
1276 let mut head = Row::new(w);
1277 head.put(term::BOLD, " frame ");
1278 head.put(
1279 term::DIM,
1280 &format!(
1281 "{} → {} · {}",
1282 stamp(f.from),
1283 stamp(f.to),
1284 dur((f.to - f.from).max(0))
1285 ),
1286 );
1287 out.push(head.done());
1288 if f.truncated {
1289 let mut r = Row::new(w);
1292 r.put(
1293 term::YELLOW,
1294 " sample — narrow the filter before concluding",
1295 );
1296 out.push(r.done());
1297 }
1298
1299 out.push(rule(w, &format!(" {} services ", f.services.len())));
1300 for (i, (name, n)) in f.services.iter().enumerate() {
1301 let sel = i == self.psel;
1302 let mut r = Row::new(w);
1303 r.put(if sel { term::REV } else { "" }, &format!(" {name}"));
1304 if *n > 1 {
1305 r.put(term::DIM, &format!(" ×{n}"));
1308 }
1309 out.push(r.fill(if sel { term::REV } else { "" }));
1310 }
1311
1312 out.push(rule(w, &format!(" {} traces ", f.traces.len())));
1313 let head = h.saturating_sub(out.len());
1314 let top = window_start(
1315 self.psel.saturating_sub(f.services.len()),
1316 head,
1317 f.traces.len(),
1318 );
1319 for (i, id) in f.traces.iter().enumerate().skip(top).take(head) {
1320 let sel = f.services.len() + i == self.psel;
1321 let style = if sel { term::REV } else { "" };
1322 let mut r = Row::new(w);
1323 r.put(style, &format!(" {id}"));
1324 out.push(r.fill(style));
1325 }
1326 out
1327 }
1328
1329 fn map_pane(&self, w: usize, h: usize) -> Vec<String> {
1331 let Some(m) = &self.map else {
1332 return Vec::new();
1333 };
1334 let mut out = Vec::with_capacity(h);
1335 let mut head = Row::new(w);
1336 head.put(term::BOLD, " map ");
1337 head.put(term::DIM, &format!("{} services", m.rows.len() - 1));
1338 if m.unresolved > 0 {
1339 head.put(
1343 term::YELLOW,
1344 &format!(
1345 " · {} spans with a parent outside the sample",
1346 m.unresolved
1347 ),
1348 );
1349 }
1350 out.push(head.done());
1351 out.push(rule(w, ""));
1352
1353 let vis = h.saturating_sub(2);
1354 let top = window_start(self.psel, vis, m.rows.len());
1355 for (i, (depth, n)) in m.rows.iter().enumerate().skip(top).take(vis) {
1356 out.push(map_row(n, *depth, i == self.psel, w));
1357 }
1358 out
1359 }
1360
1361 fn alerts_pane(&self, w: usize, h: usize) -> Vec<String> {
1368 let mut out = Vec::with_capacity(h);
1369 let firing = self
1370 .alerts
1371 .iter()
1372 .filter(|a| a["state"].as_str() == Some("firing"))
1373 .count();
1374 let mut head = Row::new(w);
1375 head.put(term::BOLD, " alerts ");
1376 head.put(term::DIM, &format!("{} rules · ", self.alerts.len()));
1377 head.put(
1378 if firing > 0 { term::RED } else { term::DIM },
1379 &format!("{firing} firing"),
1380 );
1381 out.push(head.done());
1382 out.push(rule(w, ""));
1383
1384 let vis = h.saturating_sub(2) / 2;
1387 let top = window_start(self.psel, vis.max(1), self.alerts.len());
1388 for (i, a) in self.alerts.iter().enumerate().skip(top).take(vis) {
1389 let sel = i == self.psel;
1390 let style = if sel { term::REV } else { "" };
1391 let state = a["state"].as_str().unwrap_or("?");
1392 let mut r = Row::new(w);
1393 r.put(
1394 style,
1395 &format!(
1396 " {:<7}",
1397 match state {
1398 "firing" => "●",
1399 "pending" => "◐",
1400 _ => "○",
1401 }
1402 ),
1403 );
1404 r.put(
1405 if sel {
1406 style
1407 } else {
1408 match state {
1409 "firing" => term::RED,
1410 "pending" => term::YELLOW,
1411 _ => term::GREEN,
1412 }
1413 },
1414 &format!("{state:<8}"),
1415 );
1416 r.put(
1417 style,
1418 &format!("{:<28}", clip(a["name"].as_str().unwrap_or("?"), 27)),
1419 );
1420 r.put(style, &alert_value(a));
1421 out.push(r.fill(style));
1422
1423 let mut r = Row::new(w);
1424 match a["error"].as_str() {
1425 Some(e) => r.put(
1428 term::RED,
1429 &format!(" {}", clip(e, w.saturating_sub(11))),
1430 ),
1431 None => r.put(
1432 term::DIM,
1433 &format!(
1434 " {} · over {}",
1435 clip(
1436 match a["filter"].as_str().unwrap_or_default() {
1437 "" => "(no filter — every record)",
1438 f => f,
1439 },
1440 w.saturating_sub(30)
1441 ),
1442 dur(i64_of(&a["over_nano"]))
1443 ),
1444 ),
1445 };
1446 out.push(r.done());
1447 }
1448 out
1449 }
1450
1451 fn diag_pane(&self, w: usize) -> Vec<String> {
1461 let Some(d) = &self.diag else {
1462 return Vec::new();
1463 };
1464 let mut out = Vec::new();
1465 let mut head = Row::new(w);
1466 head.put(term::BOLD, " node ");
1467 head.put(
1468 term::DIM,
1469 &format!(
1470 "up {} · peak rss {} · ",
1471 since(i64_of(&d["uptime_s"])),
1472 bytes(i64_of(&d["peak_rss_bytes"]) as f64)
1473 ),
1474 );
1475 let free = num(&d["free_fraction"]);
1477 head.put(
1478 match free {
1479 Some(f) if f < 0.1 => term::RED,
1480 Some(f) if f < 0.2 => term::YELLOW,
1481 _ => term::DIM,
1482 },
1483 &match free {
1484 Some(f) => format!("disk {:.0}% free", f * 100.0),
1485 None => "disk unreadable".into(),
1486 },
1487 );
1488 if i64_of(&d["degraded_syncs"]) > 0 {
1492 head.put(
1493 term::YELLOW,
1494 &format!(
1495 " · degraded syncs {}",
1496 tally(i64_of(&d["degraded_syncs"]) as f64)
1497 ),
1498 );
1499 }
1500 out.push(head.done());
1501
1502 let q = &d["queries"];
1503 out.push(rule(w, " queries "));
1504 out.push(kv(w, "served", &tally(num(&q["count"]).unwrap_or(0.0))));
1505 out.push(kv(
1506 w,
1507 "mean",
1508 &format!("{:.2} ms", num(&q["mean_ms"]).unwrap_or(0.0)),
1509 ));
1510 out.push(kv(
1511 w,
1512 "max",
1513 &format!("{:.2} ms", num(&q["max_ms"]).unwrap_or(0.0)),
1514 ));
1515
1516 for signal in ["logs", "traces", "metrics"] {
1517 let s = &d["signals"][signal];
1518 if s.is_badvalue() {
1519 continue;
1520 }
1521 out.push(rule(w, &format!(" {signal} ")));
1522 let (rows, on_disk) = (i64_of(&s["rows"]) as f64, i64_of(&s["bytes"]) as f64);
1523 out.push(kv(w, "rows written", &tally(rows)));
1524 out.push(kv(
1525 w,
1526 "blocks",
1527 &format!(
1528 "{} on disk · {} published",
1529 match num(&s["blocks_on_disk"]) {
1530 Some(b) => tally(b),
1531 None => "?".into(),
1534 },
1535 tally(i64_of(&s["blocks_published"]) as f64)
1536 ),
1537 ));
1538 out.push(kv(
1542 w,
1543 "bytes on disk",
1544 &match rows > 0.0 {
1545 true => format!("{} · {:.0} B/row", bytes(on_disk), on_disk / rows),
1546 false => bytes(on_disk),
1547 },
1548 ));
1549 if let Some(age) = num(&s["open_block_age_s"]) {
1550 out.push(kv(w, "open block", &since(age as i64)));
1551 }
1552 let (shed, failed, refused) = (
1553 i64_of(&s["shed"]),
1554 i64_of(&s["failed"]),
1555 i64_of(&s["refused"]),
1556 );
1557 let mut r = Row::new(w);
1558 r.put(term::DIM, &format!(" {:<16}", "rejected"));
1559 r.put(
1560 if shed + failed + refused > 0 {
1561 term::YELLOW
1562 } else {
1563 ""
1564 },
1565 &format!("{shed} shed · {failed} failed · {refused} refused"),
1566 );
1567 out.push(r.done());
1568 if let Some(secs) = num(&s["stalled_s"]) {
1571 let mut r = Row::new(w);
1572 r.put(term::RED, &format!(" {:<16}", "stalled"));
1573 r.put(
1574 term::RED,
1575 &format!(
1576 "{} — this node cannot store this signal",
1577 since(secs as i64)
1578 ),
1579 );
1580 out.push(r.done());
1581 }
1582 }
1583 out
1584 }
1585
1586 fn waterfall(&self, w: usize, h: usize) -> Vec<String> {
1587 let Some(t) = &self.trace else {
1588 return Vec::new();
1589 };
1590 let mut out = Vec::with_capacity(h);
1591 let mut head = Row::new(w);
1592 head.put(term::BOLD, " trace ").plain(&t.id);
1593 head.put(
1594 term::DIM,
1595 &format!(" · {} spans · {}", t.spans.len(), dur(t.span_ns.max(0))),
1596 );
1597 out.push(head.done());
1598 out.push(rule(w, ""));
1599
1600 let namew = (w / 3).clamp(16, 46);
1604 let svcw = (w / 6).clamp(8, 20);
1605 let ends = [namew, namew + svcw, w.saturating_sub(11), w];
1608
1609 let lines: Vec<String> = t
1610 .spans
1611 .iter()
1612 .enumerate()
1613 .flat_map(|(i, (s, depth))| {
1614 let mut rows = vec![span_bar(s, *depth, i == t.sel, ends, t)];
1615 for e in s["events"].as_vec().into_iter().flatten() {
1616 let mut r = Row::new(w);
1617 let at = i64_of(&e["time_unix_nano"]) - t.t0;
1618 r.plain(&" ".repeat((depth + 2).min(namew)));
1619 r.put(term::YELLOW, "● ");
1620 r.plain(e["name"].as_str().unwrap_or("event"));
1621 r.put(term::DIM, &format!(" +{}", dur(at.max(0))));
1622 rows.push(r.done());
1623 }
1624 for l in s["links"].as_vec().into_iter().flatten() {
1625 let mut r = Row::new(w);
1626 r.plain(&" ".repeat((depth + 2).min(namew)));
1627 r.put(term::BLUE, "↗ ");
1628 r.put(term::DIM, "trace ");
1629 r.plain(l["trace_id"].as_str().unwrap_or("?"));
1630 rows.push(r.done());
1631 }
1632 rows
1633 })
1634 .collect();
1635
1636 let selected_line = t
1640 .spans
1641 .iter()
1642 .take(t.sel)
1643 .map(|(s, _)| {
1644 1 + s["events"].as_vec().map_or(0, Vec::len)
1645 + s["links"].as_vec().map_or(0, Vec::len)
1646 })
1647 .sum::<usize>();
1648 let vis = h.saturating_sub(2);
1649 let top = window_start(selected_line, vis, lines.len());
1650 out.extend(lines.into_iter().skip(top).take(vis));
1651 out
1652 }
1653}
1654
1655impl FrameView {
1656 fn new(y: &Yaml) -> FrameView {
1657 let mut services: Vec<(String, usize)> = Vec::new();
1658 for e in y["entities"].as_vec().into_iter().flatten() {
1659 let name = e["name"].as_str().unwrap_or("unknown").to_owned();
1660 match services.iter_mut().find(|(n, _)| *n == name) {
1661 Some((_, n)) => *n += 1,
1662 None => services.push((name, 1)),
1663 }
1664 }
1665 services.sort_by(|a, b| a.0.cmp(&b.0));
1668 FrameView {
1669 from: i64_of(&y["from"]),
1671 to: i64_of(&y["to"]),
1672 truncated: y["truncated"].as_bool().unwrap_or(false),
1673 services,
1674 traces: y["traces"]
1675 .as_vec()
1676 .into_iter()
1677 .flatten()
1678 .filter_map(|t| t.as_str().map(str::to_owned))
1679 .collect(),
1680 }
1681 }
1682
1683 fn len(&self) -> usize {
1684 self.services.len() + self.traces.len()
1685 }
1686}
1687
1688impl MapView {
1689 fn new(y: &Yaml) -> MapView {
1697 let nodes = y["nodes"].as_vec().map_or(&[][..], Vec::as_slice);
1698 let edges = y["edges"].as_vec().map_or(&[][..], Vec::as_slice);
1699 let entry = {
1703 let mut h = yaml_rust2::yaml::Hash::new();
1704 for k in ["key", "name"] {
1705 h.insert(Yaml::String(k.into()), Yaml::String(ENTRY_KEY.into()));
1706 }
1707 Yaml::Hash(h)
1708 };
1709
1710 let mut rows: Vec<(usize, Yaml)> = Vec::with_capacity(nodes.len() + 1);
1711 if nodes.is_empty() {
1712 return MapView {
1713 rows,
1714 unresolved: 0,
1715 };
1716 }
1717 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1718 let mut stack = vec![(ENTRY_KEY.to_owned(), 0usize)];
1719 while let Some((key, depth)) = stack.pop() {
1720 if !seen.insert(key.clone()) {
1721 continue;
1722 }
1723 let node = match key == ENTRY_KEY {
1724 true => &entry,
1725 false => match nodes.iter().find(|n| n["key"].as_str() == Some(&key)) {
1726 Some(n) => n,
1727 None => continue,
1730 },
1731 };
1732 rows.push((depth, node.clone()));
1733 let mut kids: Vec<(&Yaml, u64)> = edges
1734 .iter()
1735 .filter(|e| e["from"].as_str() == Some(&key))
1736 .map(|e| (e, e["calls"].as_i64().unwrap_or(0).max(0) as u64))
1737 .collect();
1738 kids.sort_by_key(|(e, calls)| (*calls, e["to"].as_str().unwrap_or("").to_owned()));
1740 stack.extend(
1741 kids.iter()
1742 .filter_map(|(e, _)| Some((e["to"].as_str()?.to_owned(), depth + 1))),
1743 );
1744 }
1745 for n in nodes {
1749 if !n["key"].as_str().is_some_and(|k| seen.contains(k)) {
1750 rows.push((0, n.clone()));
1751 }
1752 }
1753 MapView {
1754 rows,
1755 unresolved: y["unresolved"].as_i64().unwrap_or(0),
1756 }
1757 }
1758}
1759
1760impl Trace {
1761 fn new(id: String, spans: &[Yaml]) -> Trace {
1762 let t0 = spans
1763 .iter()
1764 .map(|s| i64_of(&s["start_time_unix_nano"]))
1765 .min()
1766 .unwrap_or(0);
1767 let t1 = spans
1768 .iter()
1769 .map(|s| i64_of(&s["start_time_unix_nano"]) + i64_of(&s["duration_nano"]))
1770 .max()
1771 .unwrap_or(t0 + 1);
1772
1773 let ids: std::collections::HashSet<&str> =
1779 spans.iter().filter_map(|s| s["span_id"].as_str()).collect();
1780 let mut order: Vec<usize> = (0..spans.len()).collect();
1781 order.sort_by_key(|&i| i64_of(&spans[i]["start_time_unix_nano"]));
1782
1783 let mut out = Vec::with_capacity(spans.len());
1784 let mut stack: Vec<(usize, usize)> = order
1785 .iter()
1786 .rev()
1787 .filter(|&&i| {
1788 !spans[i]["parent_span_id"]
1789 .as_str()
1790 .is_some_and(|p| ids.contains(p))
1791 })
1792 .map(|&i| (i, 0))
1793 .collect();
1794 while let Some((i, depth)) = stack.pop() {
1795 out.push((spans[i].clone(), depth));
1796 let me = spans[i]["span_id"].as_str().unwrap_or("");
1797 stack.extend(
1798 order
1799 .iter()
1800 .rev()
1801 .filter(|&&c| spans[c]["parent_span_id"].as_str() == Some(me) && c != i)
1802 .map(|&c| (c, depth + 1)),
1803 );
1804 }
1805 if out.len() < spans.len() {
1808 let seen: std::collections::HashSet<String> = out
1809 .iter()
1810 .filter_map(|(s, _)| s["span_id"].as_str().map(str::to_owned))
1811 .collect();
1812 for &i in &order {
1813 if !spans[i]["span_id"]
1814 .as_str()
1815 .is_some_and(|s| seen.contains(s))
1816 {
1817 out.push((spans[i].clone(), 0));
1818 }
1819 }
1820 }
1821
1822 Trace {
1823 id,
1824 spans: out,
1825 t0,
1826 span_ns: t1 - t0,
1827 sel: 0,
1828 }
1829 }
1830}
1831
1832fn log_row(row: &Yaml, w: usize, sel: bool) -> String {
1835 let style = if sel { term::REV } else { "" };
1836 let sev = row["severity_number"].as_i64().unwrap_or(0);
1837 let mut r = Row::new(w);
1838 r.put(style, &format!(" {} ", hms(i64_of(&row["time_unix_nano"]))));
1839 r.put(
1840 if sel { style } else { sev_style(sev) },
1841 &format!(
1842 "{:<6}",
1843 clip(row["severity_text"].as_str().unwrap_or("-"), 6)
1844 ),
1845 );
1846 r.plain(" ");
1847 r.put(
1848 if sel { style } else { term::DIM },
1849 &format!("{:<16}", clip(service(row), 16)),
1850 );
1851 r.plain(" ");
1852 r.put(style, row["body"].as_str().unwrap_or(""));
1853 r.fill(style)
1854}
1855
1856fn span_row(row: &Yaml, w: usize, sel: bool, scale: i64) -> String {
1857 let style = if sel { term::REV } else { "" };
1858 let d = i64_of(&row["duration_nano"]);
1859 let error = row["status_code"].as_i64() == Some(2);
1860 let mut r = Row::new(w);
1861 r.put(
1862 style,
1863 &format!(" {} ", hms(i64_of(&row["start_time_unix_nano"]))),
1864 );
1865 r.put(style, &format!("{:>9} ", dur(d)));
1866 r.put(
1867 if sel { style } else { term::DIM },
1868 &format!("{:<16}", clip(service(row), 16)),
1869 );
1870 r.plain(" ");
1871 let name_style = match (sel, error) {
1872 (true, _) => style,
1873 (_, true) => term::RED,
1874 _ => "",
1875 };
1876 r.put(
1877 name_style,
1878 &format!("{:<30}", clip(row["name"].as_str().unwrap_or(""), 30)),
1879 );
1880 if error {
1881 r.put(if sel { style } else { term::RED }, " ERROR");
1882 }
1883 let bar = (d as f64 / scale as f64 * r.left().saturating_sub(2) as f64) as usize;
1886 r.plain(" ");
1887 r.repeat(if sel { style } else { term::BLUE }, '▂', bar.max(1));
1888 r.fill(style)
1889}
1890
1891#[allow(clippy::too_many_arguments)]
1892fn span_bar(s: &Yaml, depth: usize, sel: bool, ends: [usize; 4], t: &Trace) -> String {
1899 let style = if sel { term::REV } else { "" };
1900 let start = i64_of(&s["start_time_unix_nano"]) - t.t0;
1901 let d = i64_of(&s["duration_nano"]);
1902 let error = s["status_code"].as_i64() == Some(2);
1903 let text = match (sel, error) {
1904 (true, _) => style,
1905 (_, true) => term::RED,
1906 _ => "",
1907 };
1908
1909 let mut r = Row::new(ends[0]);
1910 let indent = " ".repeat(depth.min(ends[0] / 2));
1911 r.put(
1912 text,
1913 &format!(" {indent}{}", s["name"].as_str().unwrap_or("")),
1914 );
1915 r.cap(ends[1]).pad_to(ends[0]);
1916 r.put(term::DIM, &format!(" {}", service(s)))
1917 .pad_to(ends[1]);
1918
1919 let barw = ends[2] - ends[1];
1922 let scale = |ns: i64| (ns as f64 / t.span_ns.max(1) as f64 * barw as f64) as usize;
1923 let off = scale(start).min(barw.saturating_sub(1));
1924 let len = scale(d).clamp(1, barw - off);
1925 r.cap(ends[2]).repeat("", ' ', off);
1926 r.repeat(if text.is_empty() { term::GREEN } else { text }, '█', len);
1927 r.pad_to(ends[2]);
1928
1929 let durw = ends[3] - ends[2] - 1;
1930 r.cap(ends[3]);
1931 r.put(term::DIM, &format!("{:>durw$} ", dur(d)));
1932 r.fill(style)
1933}
1934
1935fn detail(row: &Yaml, w: usize) -> Vec<String> {
1937 let mut out = Vec::new();
1938 let line = |k: &str, v: &str, style: &str| {
1939 let mut r = Row::new(w);
1940 r.put(term::DIM, &format!(" {k:<26}"));
1941 r.put(style, v);
1942 r.done()
1943 };
1944 for (k, v) in pairs(row) {
1945 if k == "attributes" || k == "events" || k == "links" || k == "points" {
1946 continue;
1947 }
1948 let pretty = match k.as_str() {
1949 "time_unix_nano" | "observed_time_unix_nano" | "start_time_unix_nano" => {
1950 v.parse::<i64>().map(stamp).unwrap_or(v.clone())
1951 }
1952 "duration_nano" => v.parse::<i64>().map(dur).unwrap_or(v.clone()),
1953 "kind" => v
1954 .parse::<i64>()
1955 .map(|k| kind(k).to_owned())
1956 .unwrap_or(v.clone()),
1957 _ => v.clone(),
1958 };
1959 out.push(line(&k, &pretty, ""));
1960 }
1961 let attrs = pairs(&row["attributes"]);
1962 if !attrs.is_empty() {
1963 out.push(rule(w, " attributes "));
1964 for (k, v) in attrs {
1965 out.push(line(&k, &v, term::CYAN));
1966 }
1967 }
1968 for (label, key) in [(" events ", "events"), (" links ", "links")] {
1969 let items = row[key].as_vec().map_or(&[][..], |v| v.as_slice());
1970 if items.is_empty() {
1971 continue;
1972 }
1973 out.push(rule(w, label));
1974 for it in items {
1975 for (k, v) in pairs(it) {
1976 if k == "attributes" {
1977 continue;
1978 }
1979 out.push(line(&k, &v, ""));
1980 }
1981 for (k, v) in pairs(&it["attributes"]) {
1982 out.push(line(&format!(" {k}"), &v, term::CYAN));
1983 }
1984 }
1985 }
1986 out
1987}
1988
1989fn help(w: usize) -> Vec<String> {
1990 const TEXT: &[(&str, &str)] = &[
1991 ("", ""),
1992 ("1 2 3 / h l", "logs, traces, metrics"),
1993 ("↑ ↓ / j k", "move the selection"),
1994 ("PgUp PgDn g G", "page, top, bottom"),
1995 ("enter", "open the selection (metrics: load the series)"),
1996 ("t", "open the trace this row points at"),
1997 ("c", "the frame around this filter: its services and traces"),
1998 (
1999 "m",
2000 "the service map, as a call tree from where traffic arrives",
2001 ),
2002 ("f", "follow: re-run the query every 3s and keep the cursor"),
2003 ("a", "alert rules and what each one is doing right now"),
2004 (
2005 "d",
2006 "node diagnostics: disk, memory, ingest and query counters",
2007 ),
2008 ("tab", "metrics: switch between names and series"),
2009 (
2010 "esc",
2011 "back out of a detail, trace, frame, map, alert or help view",
2012 ),
2013 ("/", "edit the filter, enter to apply"),
2014 ("[ ]", "shrink or grow the time window"),
2015 ("+ -", "halve or double the row limit"),
2016 ("r", "re-run the query"),
2017 ("q", "quit"),
2018 ("", ""),
2019 ("filter syntax", "space-separated terms, all AND-ed"),
2020 (
2021 " service.name=checkout",
2022 "an attribute, matched at all three levels",
2023 ),
2024 (
2025 " severity_number>=17",
2026 "a root column: = != < <= > >= and ~ for contains",
2027 ),
2028 (
2029 " body~\"connection refused\"",
2030 "quote a value that has spaces in it",
2031 ),
2032 (
2033 " refused",
2034 "a word on its own searches body, or a span's name",
2035 ),
2036 ("", ""),
2037 (
2038 "on a local directory",
2039 "queries run in-process; no server needs to be up",
2040 ),
2041 (
2042 "",
2043 "a and d need --addr: they report a process, not a directory",
2044 ),
2045 ];
2046 TEXT.iter()
2047 .map(|(k, v)| {
2048 let mut r = Row::new(w);
2049 r.put(term::BOLD, &format!(" {k:<28}"));
2050 r.put(term::DIM, v);
2051 r.done()
2052 })
2053 .collect()
2054}
2055
2056fn body_h(h: usize) -> usize {
2059 h.saturating_sub(4).max(1)
2060}
2061
2062fn window_start(sel: usize, height: usize, len: usize) -> usize {
2064 if len <= height {
2065 return 0;
2066 }
2067 sel.saturating_sub(height / 2).min(len - height)
2068}
2069
2070fn rule(w: usize, title: &str) -> String {
2071 let mut r = Row::new(w);
2072 r.put(term::DIM, "──");
2073 if !title.is_empty() {
2074 r.put(term::DIM, title);
2075 }
2076 let left = r.left();
2077 r.repeat(term::DIM, '─', left);
2078 r.done()
2079}
2080
2081fn array(y: &Yaml) -> Vec<Yaml> {
2082 y.as_vec().cloned().unwrap_or_default()
2083}
2084
2085fn pairs(y: &Yaml) -> Vec<(String, String)> {
2087 y.as_hash()
2088 .map(|h| {
2089 h.iter()
2090 .filter_map(|(k, v)| Some((k.as_str()?.to_owned(), text(v))))
2091 .collect()
2092 })
2093 .unwrap_or_default()
2094}
2095
2096fn text(y: &Yaml) -> String {
2109 nested(y, 4)
2110}
2111
2112fn nested(y: &Yaml, depth: usize) -> String {
2113 match y {
2114 Yaml::String(s) => s.clone(),
2115 Yaml::Integer(i) => i.to_string(),
2116 Yaml::Real(r) => r.clone(),
2117 Yaml::Boolean(b) => b.to_string(),
2118 Yaml::Null => "null".into(),
2119 Yaml::Array(a) if depth == 0 => format!("[{} items]", a.len()),
2120 Yaml::Hash(h) if depth == 0 => format!("{{{} keys}}", h.len()),
2121 Yaml::Array(a) => {
2122 let items: Vec<String> = a.iter().map(|v| nested(v, depth - 1)).collect();
2123 format!("[{}]", items.join(", "))
2124 }
2125 Yaml::Hash(h) => {
2126 let items: Vec<String> = h
2127 .iter()
2128 .map(|(k, v)| format!("{}: {}", nested(k, 0), nested(v, depth - 1)))
2131 .collect();
2132 format!("{{{}}}", items.join(", "))
2133 }
2134 _ => String::new(),
2135 }
2136}
2137
2138fn i64_of(y: &Yaml) -> i64 {
2144 y.as_i64()
2145 .or_else(|| y.as_str()?.parse().ok())
2146 .unwrap_or_default()
2147}
2148
2149fn service_term(name: &str) -> String {
2158 match name.split_once('"') {
2159 Some((head, _)) => format!("service.name~\"{head}\""),
2160 None => format!("service.name=\"{name}\""),
2161 }
2162}
2163
2164fn map_row(n: &Yaml, depth: usize, sel: bool, w: usize) -> String {
2166 let style = if sel { term::REV } else { "" };
2167 let entry = n["key"].as_str() == Some(ENTRY_KEY);
2168 let errors = n["errors"].as_i64().unwrap_or(0);
2169 let namew = w.saturating_sub(44).clamp(16, 40);
2173
2174 let mut r = Row::new(namew);
2175 let indent = " ".repeat(depth.min(namew / 4));
2178 r.put(
2179 match (sel, entry, errors > 0) {
2180 (true, ..) => style,
2181 (_, true, _) => term::DIM,
2182 (_, _, true) => term::RED,
2183 _ => "",
2184 },
2185 &format!(" {indent}{}", n["name"].as_str().unwrap_or("?")),
2186 );
2187 r.cap(w).pad_to(namew);
2188 if !entry {
2189 r.put(
2190 term::DIM,
2191 &format!("{:>9} spans ", n["spans"].as_i64().unwrap_or(0)),
2192 );
2193 match errors {
2194 0 => r.put(term::DIM, &format!("{:>5}{:9}", "-", "")),
2197 e => r.put(
2198 if sel { style } else { term::RED },
2199 &format!("{e:>5} errors "),
2200 ),
2201 };
2202 r.put(
2203 term::DIM,
2204 &format!("{:>9} avg", dur(i64_of(&n["avg_nano"]))),
2205 );
2206 }
2207 r.fill(style)
2208}
2209
2210fn num(y: &Yaml) -> Option<f64> {
2211 match y {
2212 Yaml::Integer(i) => Some(*i as f64),
2213 Yaml::Real(r) => r.parse().ok(),
2214 Yaml::String(s) => s.parse().ok(),
2218 _ => None,
2219 }
2220}
2221
2222fn service(row: &Yaml) -> &str {
2223 row["attributes"]["service.name"].as_str().unwrap_or("-")
2224}
2225
2226fn sev_style(n: i64) -> &'static str {
2227 match n {
2228 17.. => term::RED,
2229 13..=16 => term::YELLOW,
2230 9..=12 => term::GREEN,
2231 _ => term::DIM,
2232 }
2233}
2234
2235fn kind(k: i64) -> &'static str {
2236 match k {
2237 1 => "internal",
2238 2 => "server",
2239 3 => "client",
2240 4 => "producer",
2241 5 => "consumer",
2242 _ => "unspecified",
2243 }
2244}
2245
2246fn clip(s: &str, n: usize) -> String {
2247 match n > 0 && s.chars().count() > n {
2248 true => s.chars().take(n.saturating_sub(1)).collect::<String>() + "…",
2249 false => s.to_owned(),
2250 }
2251}
2252
2253fn hms(ns: i64) -> String {
2259 let (tm, ms) = civil(ns);
2260 format!(
2261 "{:02}:{:02}:{:02}.{ms:03}",
2262 tm.tm_hour, tm.tm_min, tm.tm_sec
2263 )
2264}
2265
2266fn stamp(ns: i64) -> String {
2267 let (tm, ms) = civil(ns);
2268 format!(
2269 "{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{ms:03}",
2270 tm.tm_year + 1900,
2271 tm.tm_mon + 1,
2272 tm.tm_mday,
2273 tm.tm_hour,
2274 tm.tm_min,
2275 tm.tm_sec
2276 )
2277}
2278
2279fn civil(ns: i64) -> (libc::tm, i64) {
2280 let secs = ns.div_euclid(1_000_000_000) as libc::time_t;
2281 let ms = ns.rem_euclid(1_000_000_000) / 1_000_000;
2282 let mut tm: libc::tm = unsafe { std::mem::zeroed() };
2288 unsafe { libc::localtime_r(&secs, &mut tm) };
2292 (tm, ms)
2293}
2294
2295fn dur(ns: i64) -> String {
2296 match ns {
2297 n if n >= 60_000_000_000 => format!(
2298 "{}m{:02}s",
2299 n / 60_000_000_000,
2300 n % 60_000_000_000 / 1_000_000_000
2301 ),
2302 n if n >= 1_000_000_000 => format!("{:.2}s", n as f64 / 1e9),
2303 n if n >= 1_000_000 => format!("{:.2}ms", n as f64 / 1e6),
2304 n if n >= 1_000 => format!("{:.1}µs", n as f64 / 1e3),
2305 n => format!("{n}ns"),
2306 }
2307}
2308
2309fn ms(d: std::time::Duration) -> String {
2310 match d.as_secs_f64() {
2311 s if s >= 1.0 => format!("{s:.2}s"),
2312 s => format!("{:.1}ms", s * 1e3),
2313 }
2314}
2315
2316fn g(v: f64) -> String {
2319 match v.abs() {
2320 0.0 => "0".into(),
2321 x if x >= 1e6 => format!("{:.1}M", v / 1e6),
2322 x if x >= 1e3 => format!("{:.1}k", v / 1e3),
2323 x if x >= 1.0 => format!("{v:.1}"),
2324 _ => format!("{v:.3}"),
2325 }
2326}
2327
2328fn kv(w: usize, k: &str, v: &str) -> String {
2330 let mut r = Row::new(w);
2331 r.put(term::DIM, &format!(" {k:<16}"));
2332 r.plain(v);
2333 r.done()
2334}
2335
2336fn since(secs: i64) -> String {
2343 match secs {
2344 s if s < 0 => "0s".into(),
2345 s if s < 60 => format!("{s}s"),
2346 s if s < 3_600 => format!("{}m {:02}s", s / 60, s % 60),
2347 s if s < 86_400 => format!("{}h {:02}m", s / 3_600, s % 3_600 / 60),
2348 s => format!("{}d {:02}h", s / 86_400, s % 86_400 / 3_600),
2349 }
2350}
2351
2352fn bytes(b: f64) -> String {
2354 const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
2355 let mut b = b;
2356 for (i, u) in UNITS.iter().enumerate() {
2357 if b < 1024.0 || i == UNITS.len() - 1 {
2358 return match i {
2359 0 => format!("{b:.0} {u}"),
2360 _ => format!("{b:.1} {u}"),
2361 };
2362 }
2363 b /= 1024.0;
2364 }
2365 unreachable!()
2366}
2367
2368fn alert_value(a: &Yaml) -> String {
2370 let (value, threshold) = (
2371 num(&a["value"]).unwrap_or(0.0),
2372 num(&a["threshold"]).unwrap_or(0.0),
2373 );
2374 let op = a["op"].as_str().unwrap_or(">");
2375 let head = match a["metric"].as_str() {
2376 Some("ratio") => format!("{:.2}% {op} {:.2}%", value * 100.0, threshold * 100.0),
2377 _ => format!("{} {op} {}", tally(value), tally(threshold)),
2378 };
2379 let matched = tally(num(&a["matched"]).unwrap_or(0.0));
2380 match num(&a["total"]) {
2381 Some(t) => format!("{head} {matched} of {}", tally(t)),
2382 None => format!("{head} {matched} records"),
2383 }
2384}
2385
2386fn tally(v: f64) -> String {
2389 match v.abs() < 1e4 {
2390 true => format!("{v:.0}"),
2391 false => g(v),
2392 }
2393}
2394
2395fn stats_line(s: &Yaml) -> String {
2396 format!(
2397 "{}/{} blocks · {} rows scanned · {} matched",
2398 s["blocks_scanned"].as_i64().unwrap_or(0),
2399 s["blocks_total"].as_i64().unwrap_or(0),
2400 s["rows_scanned"].as_i64().unwrap_or(0),
2401 s["rows_matched"].as_i64().unwrap_or(0),
2402 )
2403}
2404
2405fn spark(v: &[f64], w: usize) -> String {
2411 const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
2412 if v.is_empty() || w == 0 {
2413 return String::new();
2414 }
2415 let (lo, hi) = (
2416 v.iter().cloned().fold(f64::INFINITY, f64::min),
2417 v.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
2418 );
2419 let span = (hi - lo).max(f64::MIN_POSITIVE);
2420 (0..w.min(v.len()))
2423 .map(|i| {
2424 let (a, b) = (
2425 i * v.len() / w.min(v.len()),
2426 (i + 1) * v.len() / w.min(v.len()),
2427 );
2428 let bucket = &v[a..b.max(a + 1).min(v.len())];
2429 let peak = bucket.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
2430 let n = ((peak - lo) / span * 7.0).round().clamp(0.0, 7.0) as usize;
2431 BARS[n]
2432 })
2433 .collect()
2434}
2435
2436fn trace_query(id: &str) -> String {
2437 let mut j = Json::new();
2438 j.obj(|j| {
2439 j.key("signal");
2440 j.str("traces");
2441 j.key("from");
2446 j.str("-3650d");
2447 j.key("to");
2448 j.str("now");
2449 j.key("limit");
2450 j.i64(2_000);
2451 j.key("where");
2452 j.arr(|j| {
2453 j.obj(|j| {
2454 j.key("field");
2455 j.str("trace_id");
2456 j.key("eq");
2457 j.str(id);
2458 });
2459 });
2460 });
2461 j.into_string()
2462}
2463
2464const OPS: [(&str, &str); 7] = [
2465 (">=", "gte"),
2466 ("<=", "lte"),
2467 ("!=", "ne"),
2468 ("=", "eq"),
2469 ("~", "contains"),
2470 (">", "gt"),
2471 ("<", "lt"),
2472];
2473
2474#[derive(Debug, PartialEq)]
2479enum Part {
2480 Term(String, &'static str, String),
2481 Word(String),
2482}
2483
2484fn parse_filter(s: &str) -> Vec<Part> {
2491 let mut out = Vec::new();
2492 for tok in tokens(s) {
2493 let mut best: Option<(usize, usize, &'static str)> = None;
2496 for (sym, op) in OPS {
2497 if let Some(p) = tok.find(sym) {
2498 let better = best.is_none_or(|(bp, bl, _)| p < bp || (p == bp && sym.len() > bl));
2499 if better {
2500 best = Some((p, sym.len(), op));
2501 }
2502 }
2503 }
2504 match best {
2505 Some((p, l, op)) => {
2508 let (k, v) = (tok[..p].trim(), tok[p + l..].trim());
2509 if !k.is_empty() && !v.is_empty() {
2510 out.push(Part::Term(k.to_owned(), op, v.to_owned()));
2511 }
2512 }
2513 None => out.push(Part::Word(tok)),
2514 }
2515 }
2516 out
2517}
2518
2519fn tokens(s: &str) -> Vec<String> {
2521 let (mut out, mut cur, mut quoted) = (Vec::new(), String::new(), false);
2522 for c in s.chars() {
2523 match c {
2524 '"' => quoted = !quoted,
2525 c if c.is_whitespace() && !quoted => {
2526 if !cur.is_empty() {
2527 out.push(std::mem::take(&mut cur));
2528 }
2529 }
2530 c => cur.push(c),
2531 }
2532 }
2533 if !cur.is_empty() {
2534 out.push(cur);
2535 }
2536 out
2537}
2538
2539fn scalar(j: &mut Json, key: &str, v: &str) {
2547 if v == "true" || v == "false" {
2548 return j.bool(v == "true");
2549 }
2550 if !key.ends_with("_id") && !key.ends_with(".id") {
2551 if let Ok(n) = v.parse::<i64>() {
2552 return j.i64(n);
2553 }
2554 if let Ok(f) = v.parse::<f64>() {
2555 return j.f64(f);
2556 }
2557 }
2558 j.str(v);
2559}
2560
2561#[cfg(test)]
2562mod tests {
2563 use super::*;
2564
2565 fn term(k: &str, op: &'static str, v: &str) -> Part {
2566 Part::Term(k.into(), op, v.into())
2567 }
2568
2569 #[test]
2570 fn filter_terms_split_on_the_longest_operator() {
2571 let f = parse_filter("service.name=checkout severity_number>=17 http.route~/api");
2572 assert_eq!(
2573 f,
2574 vec![
2575 term("service.name", "eq", "checkout"),
2576 term("severity_number", "gte", "17"),
2577 term("http.route", "contains", "/api"),
2578 ]
2579 );
2580 assert_eq!(parse_filter("k!=v"), vec![term("k", "ne", "v")]);
2582 assert_eq!(
2585 parse_filter("justawordse"),
2586 vec![Part::Word("justawordse".into())]
2587 );
2588 assert!(parse_filter("=v k=").is_empty());
2591 }
2592
2593 #[test]
2594 fn a_quoted_value_keeps_its_spaces() {
2595 assert_eq!(
2596 parse_filter("body~\"connection refused\" a=b"),
2597 vec![
2598 term("body", "contains", "connection refused"),
2599 term("a", "eq", "b"),
2600 ]
2601 );
2602 }
2603
2604 #[test]
2608 fn a_bare_word_searches_the_tabs_message_column() {
2609 use mira_core::query::{Op, Target, Value};
2610
2611 let mut app = App::new(Source::Local("/nonexistent".into()));
2612 app.filter = "\"connection refused\" service.name=checkout".into();
2613 let q = crate::api::parse_search(&app.rows_query(), 0).unwrap();
2614 assert_eq!(q.terms.len(), 2);
2615 assert!(matches!(&q.terms[0].target, Target::Field(k) if k == "body"));
2616 assert_eq!(q.terms[0].op, Op::Contains);
2617 assert_eq!(q.terms[0].value, Value::Str("connection refused".into()));
2618
2619 app.tab = Tab::Traces;
2621 let q = crate::api::parse_search(&app.rows_query(), 0).unwrap();
2622 assert!(matches!(&q.terms[0].target, Target::Field(k) if k == "name"));
2623
2624 app.tab = Tab::Logs;
2627 app.filter = "500".into();
2628 let q = crate::api::parse_search(&app.rows_query(), 0).unwrap();
2629 assert_eq!(q.terms[0].value, Value::Str("500".into()));
2630 }
2631
2632 #[test]
2635 fn a_bare_word_on_the_metrics_tab_says_it_was_ignored() {
2636 let mut app = App::new(Source::Local("/nonexistent".into()));
2637 app.tab = Tab::Metrics;
2638 app.filter = "refused pod=a".into();
2639 assert_eq!(app.ignored_word().as_deref(), Some("refused"));
2640
2641 let q = crate::api::parse_series(&app.series_query(), 0).unwrap();
2644 assert_eq!(q.terms.len(), 1);
2645
2646 app.filter = "pod=a refused".into();
2650 assert_eq!(app.ignored_word().as_deref(), Some("refused"));
2651
2652 for tab in [Tab::Logs, Tab::Traces] {
2654 app.tab = tab;
2655 assert_eq!(app.ignored_word(), None);
2656 }
2657 }
2658
2659 #[test]
2663 fn the_filter_compiles_to_a_query_the_api_parses() {
2664 let mut app = App::new(Source::Local("/nonexistent".into()));
2665 app.tab = Tab::Traces;
2666 app.filter =
2667 "service.name=checkout duration_nano>=500000 trace_id=00112233445566778899aabbccddeeff"
2668 .into();
2669 let body = app.rows_query();
2670 let q = crate::api::parse_search(&body, 1_000_000_000_000_000_000).unwrap();
2671
2672 use mira_core::query::{Op, Signal, Target, Value};
2673 assert_eq!(q.signal, Signal::Traces);
2674 assert_eq!(q.limit, 200);
2675 assert_eq!(q.from, 1_000_000_000_000_000_000 - 3_600_000_000_000);
2676 assert_eq!(q.terms.len(), 3);
2677 assert!(matches!(&q.terms[0].target, Target::Attr(k) if k == "service.name"));
2679 assert!(matches!(&q.terms[1].target, Target::Field(k) if k == "duration_nano"));
2680 assert_eq!(q.terms[1].op, Op::Gte);
2681 assert_eq!(q.terms[1].value, Value::Int(500_000));
2682 assert!(matches!(&q.terms[2].value, Value::Str(s) if s.len() == 32));
2684
2685 app.filter = "ok=true ratio>=0.5 span.id=00000000000000042".into();
2690 let q = crate::api::parse_search(&app.rows_query(), 0).unwrap();
2691 assert_eq!(q.terms[0].value, Value::Bool(true));
2692 assert_eq!(q.terms[1].value, Value::Double(0.5));
2693 assert!(matches!(&q.terms[2].value, Value::Str(_)));
2695 }
2696
2697 #[test]
2700 fn the_signal_decides_whether_a_key_is_a_column() {
2701 let mut app = App::new(Source::Local("/nonexistent".into()));
2702 app.filter = "name=checkout".into();
2703 let logs = crate::api::parse_search(&app.rows_query(), 0).unwrap();
2704 app.tab = Tab::Traces;
2705 let traces = crate::api::parse_search(&app.rows_query(), 0).unwrap();
2706 assert!(matches!(&logs.terms[0].target, Target::Attr(_)));
2707 assert!(matches!(&traces.terms[0].target, Target::Field(_)));
2708 use mira_core::query::Target;
2709 }
2710
2711 #[test]
2722 fn the_field_list_is_the_schema() {
2723 let skip = ["id", "resource_id", "scope_id", "body_ser"];
2727 let js = include_str!("../ui/src/lib/api.js");
2728 for (tab, key, schema) in [
2729 (Tab::Logs, "logs", &mira_core::schema::LOGS),
2730 (Tab::Traces, "traces", &mira_core::schema::SPANS),
2731 ] {
2732 let mut want: Vec<&str> = schema
2733 .fields()
2734 .iter()
2735 .map(|f| f.name().as_str())
2736 .filter(|n| !skip.contains(n))
2737 .collect();
2738 let mut got = tab.fields().to_vec();
2739 let arr = js
2742 .split_once(&format!("\n {key}: ["))
2743 .expect("FIELDS key")
2744 .1;
2745 let mut browser: Vec<&str> = arr[..arr.find(']').expect("closing bracket")]
2746 .split('\'')
2747 .skip(1)
2748 .step_by(2)
2749 .collect();
2750 want.sort_unstable();
2751 got.sort_unstable();
2752 browser.sort_unstable();
2753 assert_eq!(got, want, "{tab:?}");
2754 assert_eq!(browser, want, "{tab:?} in ui/src/lib/api.js");
2755 }
2756 }
2757
2758 #[test]
2762 fn nested_values_render_inline_down_to_a_depth() {
2763 let v = |s: &str| text(&crate::api::parse(s).unwrap()["v"]);
2764 assert_eq!(v(r#"{"v":["mira","serve"]}"#), "[mira, serve]");
2765 assert_eq!(v(r#"{"v":{"role":"user","n":2}}"#), "{role: user, n: 2}");
2766 assert_eq!(v(r#"{"v":[{"type":"text"}]}"#), "[{type: text}]");
2767 assert_eq!(v(r#"{"v":[[[[["deep"]]]]]}"#), "[[[[[1 items]]]]]");
2770 assert_eq!(v(r#"{"v":[1.5,true,null]}"#), "[1.5, true, null]");
2772 assert_eq!(v(r#"{"v":[[[[{"a":1,"b":2}]]]]}"#), "[[[[{2 keys}]]]]");
2774 assert_eq!(v("{}"), "");
2778 }
2779
2780 #[test]
2786 fn each_formatter_covers_the_range_it_was_written_for() {
2787 assert_eq!(dur(940), "940ns");
2789 assert_eq!(dur(1_500), "1.5µs");
2790 assert_eq!(dur(2_500_000), "2.50ms");
2791 assert_eq!(dur(1_250_000_000), "1.25s");
2792 assert_eq!(dur(90_000_000_000), "1m30s");
2793
2794 assert_eq!(since(-1), "0s");
2796 assert_eq!(since(41), "41s");
2797 assert_eq!(since(125), "2m 05s");
2798 assert_eq!(since(7_384), "2h 03m");
2799 assert_eq!(since(93_784), "1d 02h");
2800
2801 assert_eq!(ms(std::time::Duration::from_micros(1_830)), "1.8ms");
2802 assert_eq!(ms(std::time::Duration::from_millis(2_500)), "2.50s");
2803
2804 assert_eq!(g(0.0), "0");
2807 assert_eq!(g(0.0481), "0.048");
2808 assert_eq!(g(12.5), "12.5");
2809 assert_eq!(g(12_040.0), "12.0k");
2810 assert_eq!(g(1_204_000.0), "1.2M");
2811
2812 assert_eq!(bytes(512.0), "512 B");
2813 assert_eq!(bytes(1536.0), "1.5 KiB");
2814 assert_eq!(bytes(1.5 * 1024f64.powi(4)), "1.5 TiB");
2815 assert_eq!(bytes(4096.0 * 1024f64.powi(4)), "4096.0 TiB");
2818
2819 let kinds: Vec<&str> = (0i64..7).map(kind).collect();
2822 assert_eq!(
2823 kinds,
2824 [
2825 "unspecified",
2826 "internal",
2827 "server",
2828 "client",
2829 "producer",
2830 "consumer",
2831 "unspecified",
2832 ]
2833 );
2834
2835 assert_eq!(sev_style(21), term::RED);
2837 assert_eq!(sev_style(13), term::YELLOW);
2838 assert_eq!(sev_style(9), term::GREEN);
2839 assert_eq!(sev_style(1), term::DIM);
2840 }
2841
2842 #[test]
2843 fn the_trace_query_is_the_bloom_indexed_shape() {
2844 let q =
2845 crate::api::parse_search(&trace_query("abababababababababababababababab"), 0).unwrap();
2846 use mira_core::query::{Op, Target};
2847 assert_eq!(q.terms.len(), 1);
2848 assert!(matches!(&q.terms[0].target, Target::Field(f) if f == "trace_id"));
2849 assert_eq!(q.terms[0].op, Op::Eq);
2850 }
2851
2852 #[test]
2856 fn spans_order_depth_first_with_orphans_kept() {
2857 let doc = crate::api::parse(
2858 r#"{"rows":[
2860 {"span_id":"02","parent_span_id":"01","start_time_unix_nano":"30","duration_nano":"5"},
2861 {"span_id":"01","start_time_unix_nano":"10","duration_nano":"100"},
2862 {"span_id":"03","parent_span_id":"01","start_time_unix_nano":"20","duration_nano":"5"},
2863 {"span_id":"04","parent_span_id":"03","start_time_unix_nano":"21","duration_nano":"1"},
2864 {"span_id":"09","parent_span_id":"ff","start_time_unix_nano":"90","duration_nano":"1"}
2865 ]}"#,
2866 )
2867 .unwrap();
2868 let t = Trace::new("abc".into(), &array(&doc["rows"]));
2869 let seen: Vec<(&str, usize)> = t
2870 .spans
2871 .iter()
2872 .map(|(s, d)| (s["span_id"].as_str().unwrap(), *d))
2873 .collect();
2874 assert_eq!(
2875 seen,
2876 vec![
2877 ("01", 0),
2878 ("03", 1),
2880 ("04", 2),
2881 ("02", 1),
2882 ("09", 0),
2884 ]
2885 );
2886 assert_eq!(t.t0, 10);
2887 assert_eq!(t.span_ns, 100);
2888 }
2889
2890 #[test]
2893 fn a_parent_cycle_still_shows_every_span() {
2894 let doc = crate::api::parse(
2895 r#"{"rows":[
2896 {"span_id":"01","parent_span_id":"02","start_time_unix_nano":10,"duration_nano":1},
2897 {"span_id":"02","parent_span_id":"01","start_time_unix_nano":20,"duration_nano":1}
2898 ]}"#,
2899 )
2900 .unwrap();
2901 let t = Trace::new("abc".into(), &array(&doc["rows"]));
2902 assert_eq!(t.spans.len(), 2);
2903 }
2904
2905 #[test]
2906 fn sparkline_scales_between_the_runs_own_bounds() {
2907 assert_eq!(
2908 spark(&[0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0], 8),
2909 "▁▂▃▄▅▆▇█"
2910 );
2911 assert_eq!(spark(&[5.0; 4], 4), "▁▁▁▁");
2913 assert_eq!(spark(&[], 8), "");
2914 assert!(spark(&[0.0, 0.0, 9.0, 0.0], 2).ends_with('█'));
2917 }
2918
2919 #[test]
2926 fn a_quoted_64_bit_field_still_reaches_the_screen() {
2927 let row = &crate::api::parse(
2928 r#"{"time_unix_nano":"1788877362987743417","severity_text":"INFO",
2929 "start_time_unix_nano":"1788877362987743417","duration_nano":"5000000",
2930 "name":"GET /x","body":"hello"}"#,
2931 )
2932 .unwrap();
2933
2934 let clock = strip(&log_row(row, 60, false));
2936 assert!(!clock.contains(&hms(0)), "{clock:?}");
2937 assert!(clock.contains(&hms(1_788_877_362_987_743_417)), "{clock:?}");
2938
2939 let span = strip(&span_row(row, 90, false, i64_of(&row["duration_nano"])));
2941 assert!(span.contains("5.00ms"), "{span:?}");
2942 assert!(span.matches('▂').count() > 1, "{span:?}");
2943
2944 assert_eq!(num(&Yaml::String("350".into())), Some(350.0));
2946 let mut app = App::new(Source::Local("/nonexistent".into()));
2947 app.series = array(
2948 &crate::api::parse(
2949 r#"{"series":[{"name":"m","attributes":{},"points":[["1","10"],["2","0"]]}]}"#,
2950 )
2951 .unwrap()["series"],
2952 );
2953 let chart = strip(&app.series_lines(80)[1]);
2954 assert!(!chart.contains("no points"), "{chart:?}");
2955 assert!(chart.contains('█'), "{chart:?}");
2956 }
2957
2958 #[test]
2963 fn a_truncated_sparkline_says_how_many_points_are_missing() {
2964 let mut app = App::new(Source::Local("/nonexistent".into()));
2965 let series = |extra: &str| {
2966 let pts: Vec<String> = (0..400)
2969 .map(|i| format!("[{i},{}]", (i - 200) * 1000))
2970 .collect();
2971 array(
2972 &crate::api::parse(&format!(
2973 r#"{{"series":[{{"name":"m","attributes":{{}},{extra}"points":[{}]}}]}}"#,
2974 pts.join(",")
2975 ))
2976 .unwrap()["series"],
2977 )
2978 };
2979
2980 app.series = series(r#""dropped_points":8240,"#);
2981 let capped = strip(&app.series_lines(80)[1]);
2982 assert!(capped.contains("+8240 dropped"), "{capped:?}");
2983
2984 assert!(capped.trim_end().ends_with("+8240 dropped"), "{capped:?}");
2988
2989 app.series = series("");
2991 let whole = strip(&app.series_lines(80)[1]);
2992 assert!(!whole.contains("dropped"), "{whole:?}");
2993 }
2994
2995 #[test]
2998 fn cursors_stay_in_range_on_an_empty_result() {
2999 let mut app = App::new(Source::Local("/nonexistent".into()));
3000 app.move_by(5);
3001 app.move_to(usize::MAX);
3002 assert_eq!(app.sel, 0);
3003 app.rows = vec![Yaml::Null, Yaml::Null, Yaml::Null];
3004 app.move_to(usize::MAX);
3005 assert_eq!(app.sel, 2);
3006 app.move_by(10);
3007 assert_eq!(app.sel, 2);
3008 app.move_by(-10);
3009 assert_eq!(app.sel, 0);
3010
3011 for mode in [Mode::Detail, Mode::Help] {
3014 app.mode = mode;
3015 app.scroll = 0;
3016 app.move_by(3);
3017 assert_eq!(app.scroll, 3, "{mode:?}");
3018 app.move_by(-9);
3019 assert_eq!(app.scroll, 0, "{mode:?}");
3020 }
3021 }
3022
3023 #[test]
3026 fn the_map_flattens_to_a_tree_without_dropping_a_service() {
3027 let node =
3028 |k: &str, n: &str| format!(r#"{{"key":"{k}","name":"{n}","spans":1,"errors":0}}"#);
3029 let edge = |a: &str, b: &str, c: u64| format!(r#"{{"from":"{a}","to":"{b}","calls":{c}}}"#);
3030 let map = |nodes: &[String], edges: &[String]| {
3031 let doc = format!(
3032 r#"{{"nodes":[{}],"edges":[{}],"unresolved":7}}"#,
3033 nodes.join(","),
3034 edges.join(",")
3035 );
3036 MapView::new(&crate::api::parse(&doc).unwrap())
3037 };
3038 let names = |m: &MapView| -> Vec<(usize, String)> {
3039 m.rows
3040 .iter()
3041 .map(|(d, n)| (*d, n["name"].as_str().unwrap_or("?").to_owned()))
3042 .collect()
3043 };
3044
3045 let m = map(
3048 &[node("1", "gw"), node("2", "slow"), node("3", "hot")],
3049 &[
3050 edge("entry", "1", 10),
3051 edge("1", "2", 1),
3052 edge("1", "3", 99),
3053 ],
3054 );
3055 assert_eq!(
3056 names(&m),
3057 [
3058 (0, "entry".into()),
3059 (1, "gw".into()),
3060 (2, "hot".into()),
3061 (2, "slow".into())
3062 ]
3063 );
3064 assert_eq!(m.unresolved, 7);
3065
3066 let m = map(
3069 &[node("1", "a"), node("2", "b")],
3070 &[edge("entry", "1", 1), edge("1", "2", 1), edge("2", "1", 1)],
3071 );
3072 assert_eq!(
3073 names(&m),
3074 [(0, "entry".into()), (1, "a".into()), (2, "b".into())]
3075 );
3076
3077 let m = map(
3081 &[node("1", "a"), node("9", "orphan")],
3082 &[edge("entry", "1", 1)],
3083 );
3084 assert_eq!(
3085 names(&m),
3086 [(0, "entry".into()), (1, "a".into()), (0, "orphan".into())]
3087 );
3088
3089 let m = map(
3092 &[node("1", "a")],
3093 &[edge("entry", "1", 1), edge("1", "404", 1)],
3094 );
3095 assert_eq!(names(&m), [(0, "entry".into()), (1, "a".into())]);
3096
3097 assert!(map(&[], &[]).rows.is_empty());
3100 }
3101
3102 #[test]
3106 fn every_line_of_the_frame_and_map_leads_back_into_a_query() {
3107 let doc = crate::api::parse(
3108 r#"{"frame":{"from":"1000","to":"5000","truncated":false,
3109 "entities":[{"key":"7","name":"checkout"},{"key":"8","name":"checkout"},
3110 {"key":"9","name":"api"}],
3111 "traces":["abababababababababababababababab"]},
3112 "map":{"nodes":[{"key":"7","name":"checkout","spans":2,"errors":1,
3113 "avg_nano":"3000"}],
3114 "edges":[{"from":"entry","to":"7","calls":2}],"unresolved":0}}"#,
3115 )
3116 .unwrap();
3117 let mut app = App::new(Source::Local("/nonexistent".into()));
3118 app.frame = Some(FrameView::new(&doc["frame"]));
3119 app.map = Some(MapView::new(&doc["map"]));
3120
3121 let f = app.frame.as_ref().unwrap();
3125 assert_eq!(f.services, [("api".into(), 1), ("checkout".into(), 2)]);
3126 assert_eq!(f.len(), 3);
3127
3128 app.mode = Mode::Frame;
3129 app.psel = 1;
3130 assert!(matches!(app.pick(), Some(Pick::Service(s)) if s == "checkout"));
3131 app.psel = 2;
3133 assert!(matches!(app.pick(), Some(Pick::Trace(t)) if t.starts_with("abab")));
3134 app.follow_pick();
3135 assert!(matches!(app.job, Some(Job::Trace(_))));
3136 app.job = None;
3138 app.open_trace();
3139 assert!(matches!(app.job, Some(Job::Trace(_))));
3140
3141 app.psel = 0;
3144 app.filter = "body~timeout".into();
3145 app.follow_pick();
3146 assert_eq!(app.filter, r#"body~timeout service.name="api""#);
3147 assert_eq!(app.mode, Mode::List);
3148 app.mode = Mode::Frame;
3150 app.follow_pick();
3151 assert_eq!(app.filter, r#"body~timeout service.name="api""#);
3152
3153 app.mode = Mode::Map;
3156 app.psel = 0;
3157 assert!(app.pick().is_none());
3158 app.psel = 1;
3159 assert!(matches!(app.pick(), Some(Pick::Service(s)) if s == "checkout"));
3160
3161 for mode in [Mode::List, Mode::Detail, Mode::Trace, Mode::Alerts] {
3165 app.mode = mode;
3166 assert!(app.pick().is_none(), "{mode:?}");
3167 }
3168
3169 assert_eq!(service_term("a b"), r#"service.name="a b""#);
3172 assert_eq!(
3173 parse_filter(&service_term("a b")),
3174 [term("service.name", "eq", "a b")]
3175 );
3176 assert_eq!(
3179 parse_filter(&service_term(r#"say "hi""#)),
3180 [term("service.name", "contains", "say")]
3181 );
3182 }
3183
3184 #[test]
3191 fn the_node_panes_show_the_numbers_the_endpoints_answered() {
3192 const ROWS: &str = r#"{"rows":[],"stats":{"blocks_total":0,"blocks_scanned":0,
3193 "rows_scanned":0,"rows_matched":0}}"#;
3194 const ALERTS: &str = r#"{"alerts":[
3195 {"name":"checkout-error-rate","state":"firing","severity":"critical",
3196 "metric":"ratio","op":">","threshold":0.05,"value":0.5,"matched":5,"total":10,
3197 "over_nano":"60000000000","for_nano":"0","since":"1","firing_since":"1",
3198 "evaluated_at":"2","signal":"traces",
3199 "filter":"attr:service.name=checkout field:status_code=2",
3200 "link":"https://m/#/traces?q=x","error":null},
3201 {"name":"quiet","state":"ok","severity":"warning","metric":"count","op":">=",
3202 "threshold":1.0,"value":0.0,"matched":0,"total":null,"over_nano":"300000000000",
3203 "for_nano":"0","since":null,"firing_since":null,"evaluated_at":"2",
3204 "signal":"logs","filter":"","link":"","error":null}],
3205 "every_nano":"5000000000"}"#;
3206 const STATS: &str = r#"{"uptime_s":93784,"peak_rss_bytes":441450496,
3207 "free_fraction":0.07,"queries":{"count":12040,"mean_ms":1.83,"max_ms":412.5},
3208 "signals":{"logs":{"shed":3,"failed":0,"refused":1,"blocks_published":71,
3209 "rows":1204000,"bytes":158000000,"blocks_on_disk":69,"open_block_age_s":12,
3210 "stalled_s":41}}}"#;
3211
3212 let addr = source::serve([ROWS, ALERTS, ROWS, STATS].map(source::ok).to_vec());
3215 let mut app = App::new(Source::Remote(addr));
3216 settle(&mut app);
3217
3218 let a = strip(&press(&mut app, Key::Char('a')));
3219 assert_eq!(app.mode, Mode::Alerts);
3220 assert!(a.contains("2 rules"), "{a}");
3221 assert!(a.contains("1 firing"), "{a}");
3222 assert!(a.contains("50.00% > 5.00%"), "{a}");
3225 assert!(a.contains("5 of 10"), "{a}");
3226 assert!(a.contains("0 records") && !a.contains("0 of 0"), "{a}");
3228 assert!(
3229 a.contains("attr:service.name=checkout field:status_code=2"),
3230 "{a}"
3231 );
3232 assert!(a.contains("(no filter — every record)"), "{a}");
3233 assert!(a.contains("over 1m00s"), "{a}");
3234
3235 press(&mut app, Key::Enter);
3238 assert_eq!(app.tab, Tab::Traces);
3239 assert_eq!(app.filter, "attr:service.name=checkout field:status_code=2");
3240 assert_eq!(app.mode, Mode::List);
3241
3242 let d = strip(&press(&mut app, Key::Char('d')));
3243 assert_eq!(app.mode, Mode::Diag);
3244 assert!(d.contains("up 1d 02h"), "{d}");
3245 assert!(d.contains("peak rss 421.0 MiB"), "{d}");
3246 assert!(d.contains("disk 7% free"), "{d}");
3247 assert!(d.contains("12.0k") && d.contains("1.83 ms"), "{d}");
3248 assert!(
3249 d.contains("69 on disk") && d.contains("71 published"),
3250 "{d}"
3251 );
3252 assert!(
3253 d.contains("1.2M"),
3254 "a million rows is not printed in full: {d}"
3255 );
3256 assert!(d.contains("150.7 MiB") && d.contains("131 B/row"), "{d}");
3258 assert!(d.contains("3 shed") && d.contains("1 refused"), "{d}");
3259 assert!(d.contains("cannot store this signal"), "{d}");
3261 assert!(!d.contains("── metrics") && !d.contains("── traces"), "{d}");
3264 }
3265
3266 #[test]
3269 fn the_node_panes_refuse_a_directory_by_name() {
3270 let mut app = App::new(Source::Local("/nonexistent".into()));
3271 app.job = None;
3272 for (k, want) in [('a', "alerts"), ('d', "stats")] {
3273 let f = strip(&press(&mut app, Key::Char(k)));
3274 assert!(app.err, "{k} should have failed");
3275 assert!(f.contains(want) && f.contains("--addr"), "{f}");
3276 assert_eq!(app.mode, Mode::List, "and must not open the pane");
3277 }
3278 }
3279
3280 #[test]
3283 fn every_view_fits_the_frame_at_any_size() {
3284 let doc = crate::api::parse(
3285 r#"{"rows":[{"time_unix_nano":"1788877362987743417","severity_number":17,
3290 "severity_text":"ERROR","body":"boom","duration_nano":"5000000",
3291 "status_code":2,"name":"GET /x","span_id":"01",
3292 "trace_id":"abababababababababababababababab",
3293 "events":[{"name":"ev","time_unix_nano":"1788877362987743500"}],
3294 "links":[{"trace_id":"cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"}],
3295 "attributes":{"service.name":"checkout"}}],
3296 "series":[{"name":"m","attributes":{"service.name":"c"},
3297 "points":[["1","2"],["2","3"]],
3298 "exemplars":[{"trace_id":"abababababababababababababababab"}]}],
3299 "names":[{"name":"http.server.duration","unit":"ms","kind":"histogram"}],
3300 "frame":{"from":"1788877362987743417","to":"1788877372987743417",
3301 "entities":[{"key":"1","name":"checkout"},{"key":"2","name":"checkout"},
3302 {"key":"3","name":"a-very-long-service-name-indeed"}],
3303 "traces":["abababababababababababababababab"],"truncated":true},
3304 "map":{"nodes":[{"key":"1","name":"frontend","spans":9,"errors":0,
3305 "avg_nano":"1000000"},
3306 {"key":"2","name":"checkout","spans":4,"errors":2,
3307 "avg_nano":"22000000"}],
3308 "edges":[{"from":"entry","to":"1","calls":9,"errors":0,
3309 "avg_nano":"1000000","max_nano":"2000000"},
3310 {"from":"1","to":"2","calls":4,"errors":2,
3311 "avg_nano":"22000000","max_nano":"90000000"}],
3312 "unresolved":3},
3313 "alerts":[{"name":"checkout-error-rate","state":"firing","severity":"critical",
3314 "metric":"ratio","op":">","threshold":0.05,"value":0.5,"matched":5,"total":10,
3315 "over_nano":"60000000000","for_nano":"0","since":"1788877362987743417",
3316 "firing_since":"1788877362987743417","evaluated_at":"1788877372987743417",
3317 "signal":"logs","filter":"attr:service.name=checkout field:severity_number>=17",
3318 "link":"https://m/#/logs?q=x","error":null},
3319 {"name":"a-rule-whose-name-is-far-too-long-to-fit-in-any-column","state":"ok",
3320 "severity":"warning","metric":"count","op":">=","threshold":1.0,"value":0.0,
3321 "matched":0,"total":null,"over_nano":"300000000000","for_nano":"120000000000",
3322 "since":null,"firing_since":null,"evaluated_at":"1788877372987743417",
3323 "signal":"traces","filter":"","link":"","error":"block directory unreadable"}]}"#,
3324 )
3325 .unwrap();
3326 let diag = crate::api::parse(
3329 r#"{"uptime_s":93784,"peak_rss_bytes":441450496,"free_fraction":0.07,
3330 "queries":{"count":12040,"mean_ms":1.83,"max_ms":412.5},
3331 "signals":{"logs":{"shed":3,"failed":0,"refused":1,"blocks_published":71,
3332 "rows":1204000,"bytes":158000000,"blocks_on_disk":69,
3333 "open_block_age_s":12,"stalled_s":41},
3334 "traces":{"shed":0,"failed":0,"refused":0,"blocks_published":0,
3335 "rows":0,"bytes":0,"blocks_on_disk":null,
3336 "open_block_age_s":null,"stalled_s":null}}}"#,
3337 )
3338 .unwrap();
3339 let mut app = App::new(Source::Local("/nonexistent".into()));
3340 app.rows = array(&doc["rows"]);
3341 app.series = array(&doc["series"]);
3342 app.names = array(&doc["names"]);
3343 app.trace = Some(Trace::new("ab".into(), &array(&doc["rows"])));
3344 app.frame = Some(FrameView::new(&doc["frame"]));
3345 app.map = Some(MapView::new(&doc["map"]));
3346 app.alerts = array(&doc["alerts"]);
3347 app.diag = Some(diag);
3348 app.stats = "x".into();
3349
3350 for (w, h) in [(40, 8), (80, 24), (200, 60), (41, 9)] {
3351 for tab in [Tab::Logs, Tab::Traces, Tab::Metrics] {
3352 for mode in [
3353 Mode::List,
3354 Mode::Filter,
3355 Mode::Detail,
3356 Mode::Trace,
3357 Mode::Span,
3358 Mode::Frame,
3359 Mode::Map,
3360 Mode::Alerts,
3361 Mode::Diag,
3362 Mode::Help,
3363 ] {
3364 app.tab = tab;
3365 app.mode = mode;
3366 let f = app.frame(w, h);
3367 assert_eq!(f.len(), h, "row count at {w}x{h}");
3368 for line in &f {
3369 let visible = line
3370 .chars()
3371 .scan(false, |esc, c| {
3372 Some(match (*esc, c) {
3373 (_, '\x1b') => {
3374 *esc = true;
3375 None
3376 }
3377 (true, c) => {
3378 *esc = !c.is_ascii_alphabetic();
3379 None
3380 }
3381 (false, c) => Some(c),
3382 })
3383 })
3384 .flatten()
3385 .count();
3386 assert!(visible <= w, "line of {visible} columns at width {w}");
3387 }
3388 }
3389 }
3390 }
3391
3392 for (w, h) in [(30, 6), (80, 7), (39, 24), (0, 0)] {
3397 let f = app.frame(w, h);
3398 assert_eq!(f.len(), 1, "{w}x{h} is one line, not a frame");
3399 assert!(strip(&f[0]).chars().count() <= w, "{w}x{h}");
3400 }
3401 assert_eq!(
3402 strip(&app.frame(30, 6)[0]),
3403 "terminal too small — need 40x8"
3404 );
3405 }
3406
3407 const W: usize = 100;
3408 const H: usize = 24;
3409
3410 fn store(name: &str) -> std::path::PathBuf {
3413 use mira_proto::collector::metrics::v1::ExportMetricsServiceRequest;
3414 use mira_proto::collector::trace::v1::ExportTraceServiceRequest;
3415 use mira_proto::metrics::v1::metric::Data;
3416 use mira_proto::metrics::v1::{
3417 AggregationTemporality, Exemplar, Gauge, Metric, NumberDataPoint, ResourceMetrics,
3418 ScopeMetrics, Sum, exemplar, number_data_point,
3419 };
3420 use mira_proto::trace::v1::{ResourceSpans, ScopeSpans, Span};
3421
3422 let dir = std::env::temp_dir().join(format!("mira-tui-{name}-{}", std::process::id()));
3423 let _ = std::fs::remove_dir_all(&dir);
3424 let now = crate::api::now_nanos() as u64;
3425 let node = mira_core::block::node_id("a");
3426
3427 let mut b = mira_core::logs::LogsBuilder::new();
3428 b.append_request(&crate::e2e::logs_export(
3429 "checkout",
3430 now - 60_000_000_000,
3431 5,
3432 ))
3433 .unwrap();
3434 mira_core::block::publish(&dir, "logs", node, 0, 0, &b.finish().unwrap()).unwrap();
3435
3436 let trace_id = vec![0xabu8; 16];
3439 let mut b = mira_core::traces::TracesBuilder::new();
3440 b.append_request(&ExportTraceServiceRequest {
3441 resource_spans: vec![ResourceSpans {
3442 scope_spans: vec![ScopeSpans {
3443 spans: (0..4u64)
3444 .map(|i| Span {
3445 trace_id: trace_id.clone().into(),
3446 span_id: vec![i as u8 + 1; 8].into(),
3447 parent_span_id: match i {
3450 0 => Vec::new(),
3451 _ => vec![i as u8; 8],
3452 }
3453 .into(),
3454 name: format!("GET /checkout/{i}"),
3455 start_time_unix_nano: now - 60_000_000_000 + i * 1_000_000,
3456 end_time_unix_nano: now - 59_000_000_000 + i * 1_000_000,
3457 ..Default::default()
3458 })
3459 .collect(),
3460 ..Default::default()
3461 }],
3462 ..Default::default()
3463 }],
3464 })
3465 .unwrap();
3466 mira_core::block::publish(&dir, "traces", node, 0, 0, &b.finish().unwrap()).unwrap();
3467
3468 let point = |i: u64| NumberDataPoint {
3469 time_unix_nano: now - 60_000_000_000 + i * 1_000_000_000,
3470 value: Some(number_data_point::Value::AsDouble(i as f64 * 1.5)),
3471 exemplars: vec![Exemplar {
3472 time_unix_nano: now - 60_000_000_000,
3473 trace_id: trace_id.clone().into(),
3474 value: Some(exemplar::Value::AsDouble(1.0)),
3475 ..Default::default()
3476 }],
3477 ..Default::default()
3478 };
3479 let mut b = mira_core::metrics::MetricsBuilder::new();
3480 b.append_request(&ExportMetricsServiceRequest {
3481 resource_metrics: vec![ResourceMetrics {
3482 scope_metrics: vec![ScopeMetrics {
3483 metrics: vec![
3484 Metric {
3485 name: "http.server.duration".into(),
3486 unit: "ms".into(),
3487 data: Some(Data::Gauge(Gauge {
3488 data_points: (0..6).map(point).collect(),
3489 })),
3490 ..Default::default()
3491 },
3492 Metric {
3493 name: "http.server.requests".into(),
3494 unit: "1".into(),
3495 data: Some(Data::Sum(Sum {
3496 aggregation_temporality: AggregationTemporality::Cumulative as i32,
3497 is_monotonic: true,
3498 data_points: (0..6).map(point).collect(),
3499 })),
3500 ..Default::default()
3501 },
3502 ],
3503 ..Default::default()
3504 }],
3505 ..Default::default()
3506 }],
3507 })
3508 .unwrap();
3509 mira_core::block::publish(&dir, "metrics", node, 0, 0, &b.finish().unwrap()).unwrap();
3510 dir
3511 }
3512
3513 fn settle(app: &mut App) -> String {
3520 let mut f = app.frame(W, H);
3521 let mut left = 8;
3524 while let Some(job) = app.job.take() {
3525 assert!(left > 0, "a job kept queueing another one");
3526 left -= 1;
3527 app.run(job, H);
3528 f = app.frame(W, H);
3529 }
3530 f.join("\n")
3531 }
3532
3533 fn press(app: &mut App, k: Key) -> String {
3534 assert!(app.key(k, H), "quit on {k:?}");
3535 settle(app)
3536 }
3537
3538 fn typed(app: &mut App, s: &str) -> String {
3539 let mut out = String::new();
3540 for c in s.chars() {
3541 out = press(app, Key::Char(c));
3542 }
3543 out
3544 }
3545
3546 fn strip(s: &str) -> String {
3547 let mut out = String::new();
3548 let mut it = s.chars();
3549 while let Some(c) = it.next() {
3550 if c == '\x1b' {
3551 for c in it.by_ref() {
3552 if c.is_ascii_alphabetic() {
3553 break;
3554 }
3555 }
3556 } else {
3557 out.push(c);
3558 }
3559 }
3560 out
3561 }
3562
3563 #[test]
3567 fn a_session_walks_the_three_tabs_and_lands_on_a_trace() {
3568 let dir = store("session");
3569 let mut app = App::new(Source::Local(dir));
3570
3571 let f = strip(&settle(&mut app));
3573 assert!(f.contains("checkout handled request"), "{f}");
3574 assert!(f.contains("ERROR"), "{f}");
3575 assert_eq!(app.rows.len(), 5);
3576
3577 let f = strip(&press(&mut app, Key::Enter));
3579 assert_eq!(app.mode, Mode::Detail);
3580 assert!(f.contains("service.name"), "{f}");
3581 press(&mut app, Key::Char('j'));
3582 assert_eq!(app.scroll, 1);
3583 press(&mut app, Key::Esc);
3584 assert_eq!(app.mode, Mode::List);
3585
3586 let f = strip(&press(&mut app, Key::Char('l')));
3588 assert_eq!(app.tab, Tab::Traces);
3589 assert!(f.contains("GET /checkout/"), "{f}");
3590 assert_eq!(app.rows.len(), 4);
3591
3592 let f = strip(&press(&mut app, Key::Char('l')));
3595 assert_eq!(app.tab, Tab::Metrics);
3596 assert!(f.contains("http.server.duration"), "{f}");
3597 assert!(!app.series.is_empty(), "the first name loaded its series");
3598
3599 assert!(!app.on_series);
3601 press(&mut app, Key::Tab);
3602 assert!(app.on_series);
3603 press(&mut app, Key::Tab);
3604 press(&mut app, Key::Char('j'));
3605 assert_eq!(app.nsel, 1);
3606 let f = strip(&press(&mut app, Key::Enter));
3607 assert!(f.contains("http.server.requests"), "{f}");
3608
3609 press(&mut app, Key::Tab);
3612 let f = strip(&press(&mut app, Key::Char('t')));
3613 assert_eq!(app.mode, Mode::Trace);
3614 let t = app.trace.as_ref().unwrap();
3615 assert_eq!(t.id, "abababababababababababababababab");
3616 assert_eq!(t.spans.len(), 4);
3617 assert!(f.contains("GET /checkout/0"), "{f}");
3618 assert!(f.contains(" GET /checkout/1"), "{f}");
3620
3621 press(&mut app, Key::Down);
3622 assert_eq!(app.trace.as_ref().unwrap().sel, 1);
3623 press(&mut app, Key::Char('t'));
3625 assert_eq!(app.mode, Mode::Trace);
3626
3627 let f = strip(&press(&mut app, Key::Enter));
3631 assert_eq!(app.mode, Mode::Span);
3632 assert!(f.contains("GET /checkout/1"), "{f}");
3633 assert!(f.contains("duration_nano"), "{f}");
3634 press(&mut app, Key::Char('j'));
3635 assert_eq!(app.scroll, 1, "the span detail scrolls like any other");
3636 press(&mut app, Key::Esc);
3638 assert_eq!(app.mode, Mode::Trace);
3639 press(&mut app, Key::Char('q'));
3641 assert_eq!(app.mode, Mode::List);
3642
3643 let f = strip(&press(&mut app, Key::Char('?')));
3645 assert_eq!(app.mode, Mode::Help);
3646 assert!(f.contains("open the trace this row points at"), "{f}");
3647 press(&mut app, Key::Char('?'));
3648 assert_eq!(app.mode, Mode::List);
3649
3650 assert!(!app.key(Key::Char('q'), H), "q on the list quits");
3651 }
3652
3653 #[test]
3657 fn the_filter_bar_edits_a_query_and_esc_puts_it_back() {
3658 let dir = store("filter");
3659 let mut app = App::new(Source::Local(dir));
3660 settle(&mut app);
3661
3662 press(&mut app, Key::Char('/'));
3663 assert_eq!(app.mode, Mode::Filter);
3664 typed(&mut app, "severity_text=WARN xx");
3665 assert_eq!(app.filter, "severity_text=WARN xx");
3666 press(&mut app, Key::Ctrl('w'));
3667 assert_eq!(app.filter, "severity_text=WARN ");
3668 press(&mut app, Key::Backspace);
3669 press(&mut app, Key::Backspace);
3670 assert_eq!(app.filter, "severity_text=WAR");
3671 typed(&mut app, "N");
3672
3673 let f = strip(&press(&mut app, Key::Enter));
3674 assert_eq!(app.mode, Mode::List);
3675 assert!(app.rows.is_empty());
3676 assert!(f.contains("no rows in this window"), "{f}");
3677
3678 press(&mut app, Key::Char('/'));
3681 typed(&mut app, " and more");
3682 press(&mut app, Key::Esc);
3683 assert_eq!(app.filter, "severity_text=WARN");
3684 assert_eq!(app.mode, Mode::List);
3685
3686 press(&mut app, Key::Char('/'));
3687 press(&mut app, Key::Ctrl('u'));
3688 press(&mut app, Key::Enter);
3689 assert_eq!(app.rows.len(), 5, "an empty filter is every row back");
3690
3691 press(&mut app, Key::Char('3'));
3694 press(&mut app, Key::Char('/'));
3695 typed(&mut app, "checkout");
3696 let f = strip(&press(&mut app, Key::Enter));
3697 assert!(f.contains("ignored"), "{f}");
3698 }
3699
3700 #[test]
3704 fn the_window_and_limit_keys_requery_and_failures_stay_on_screen() {
3705 let dir = store("window");
3706 let mut app = App::new(Source::Local(dir.clone()));
3707 settle(&mut app);
3708
3709 assert_eq!(app.win, 2);
3710 press(&mut app, Key::Char('['));
3711 assert_eq!(app.win, 1);
3712 for _ in 0..9 {
3713 press(&mut app, Key::Char(']'));
3714 }
3715 assert_eq!(app.win, WINDOWS.len() - 1, "clamped at the widest window");
3716 let f = strip(&press(&mut app, Key::Char('[')));
3717 assert!(f.contains("7d"), "{f}");
3718
3719 assert_eq!(app.limit, 200);
3720 press(&mut app, Key::Char('+'));
3721 assert_eq!(app.limit, 400);
3722 press(&mut app, Key::Char('-'));
3723 press(&mut app, Key::Char('-'));
3724 assert_eq!(app.limit, 100);
3725 press(&mut app, Key::Char('r'));
3726 assert_eq!(app.rows.len(), 5);
3727
3728 press(&mut app, Key::End);
3730 assert_eq!(app.sel, 4);
3731 press(&mut app, Key::PageUp);
3732 assert_eq!(app.sel, 0);
3733 press(&mut app, Key::PageDown);
3734 assert_eq!(app.sel, 4);
3735 press(&mut app, Key::Home);
3736 assert_eq!(app.sel, 0);
3737
3738 app.rows.clear();
3740 let f = strip(&press(&mut app, Key::Char('t')));
3741 assert!(app.err);
3742 assert!(f.contains("nothing here carries a trace id"), "{f}");
3743
3744 std::fs::remove_dir_all(&dir).unwrap();
3747 std::fs::write(&dir, b"not a directory").unwrap();
3748 let f = strip(&press(&mut app, Key::Char('r')));
3749 assert!(app.err, "{f}");
3750 assert!(
3751 f.contains("logs"),
3752 "the failure names what it could not read: {f}"
3753 );
3754 }
3755
3756 #[test]
3765 fn the_frame_and_map_panes_open_from_a_keystroke() {
3766 let dir = store("panes");
3767 let mut app = App::new(Source::Local(dir));
3768 settle(&mut app);
3769
3770 press(&mut app, Key::Char('3'));
3773 let f = strip(&press(&mut app, Key::Char('c')));
3774 assert!(app.err, "{f}");
3775 assert!(f.contains("switch tab first"), "{f}");
3776 assert_eq!(app.mode, Mode::List);
3777
3778 press(&mut app, Key::Char('h'));
3780 assert_eq!(app.tab, Tab::Traces);
3781 press(&mut app, Key::Char('h'));
3782 assert_eq!(app.tab, Tab::Logs);
3783 press(&mut app, Key::Char('1'));
3784 assert_eq!(app.tab, Tab::Logs, "the tab already on is not a reload");
3785 press(&mut app, Key::Char('2'));
3786 assert_eq!(app.tab, Tab::Traces);
3787
3788 let f = strip(&press(&mut app, Key::Char('c')));
3789 assert_eq!(app.mode, Mode::Frame);
3790 assert!(f.contains("services") && f.contains("checkout"), "{f}");
3791 assert!(f.contains("traces"), "{f}");
3792
3793 let last = app.frame.as_ref().unwrap().len() - 1;
3795 press(&mut app, Key::Char('G'));
3796 assert_eq!(app.psel, last);
3797 press(&mut app, Key::Char('j'));
3798 assert_eq!(
3799 app.psel, last,
3800 "clamped at the end of the pane, not the list"
3801 );
3802 press(&mut app, Key::Char('k'));
3803 press(&mut app, Key::Char('g'));
3804 assert_eq!(app.psel, 0);
3805
3806 assert!(app.frame.as_ref().unwrap().services.is_empty());
3810 let f = strip(&press(&mut app, Key::Enter));
3811 assert_eq!(app.mode, Mode::Trace, "{f}");
3812 press(&mut app, Key::Char('q'));
3813 assert_eq!(app.mode, Mode::List);
3814
3815 let f = strip(&press(&mut app, Key::Char('m')));
3819 assert_eq!(app.mode, Mode::Map);
3820 assert!(f.contains("map"), "{f}");
3821
3822 assert_eq!(app.psel, 0);
3827 press(&mut app, Key::Enter);
3828 assert_eq!(app.mode, Mode::Map, "enter on entry opens nothing");
3829 assert!(
3830 app.filter.is_empty(),
3831 "and filters on nothing: {}",
3832 app.filter
3833 );
3834 let f = strip(&press(&mut app, Key::Char('t')));
3835 assert_eq!(app.mode, Mode::Map, "{f}");
3836 assert!(f.contains("nothing here carries a trace id"), "{f}");
3837
3838 press(&mut app, Key::Char('j'));
3840 press(&mut app, Key::Enter);
3841 assert_eq!(app.mode, Mode::List);
3842 assert!(app.filter.contains("service.name="), "{}", app.filter);
3843
3844 app.filter.clear();
3846 let f = strip(&press(&mut app, Key::Char('f')));
3847 assert!(app.tail, "{f}");
3848 assert!(f.contains("follow"), "{f}");
3849 press(&mut app, Key::Char('f'));
3850 assert!(!app.tail);
3851 press(&mut app, Key::Char('3'));
3852 let f = strip(&press(&mut app, Key::Char('f')));
3853 assert!(app.err, "{f}");
3854 assert!(f.contains("logs or traces list"), "{f}");
3855
3856 press(&mut app, Key::Char('2'));
3859 app.filter = "service.name=nosuchservice".into();
3860 let f = strip(&press(&mut app, Key::Char('c')));
3861 assert_eq!(app.mode, Mode::List, "{f}");
3862 assert!(f.contains("no frame to widen"), "{f}");
3863
3864 let empty = std::env::temp_dir().join("mira-tui-nomap");
3868 std::fs::create_dir_all(&empty).unwrap();
3869 let mut app = App::new(Source::Local(empty));
3870 settle(&mut app);
3871 let f = strip(&press(&mut app, Key::Char('m')));
3872 assert_eq!(app.mode, Mode::List, "{f}");
3873 assert!(f.contains("there is no map"), "{f}");
3874
3875 let f = strip(&press(&mut app, Key::Char('3')));
3880 assert_eq!(app.tab, Tab::Metrics);
3881 assert!(f.contains("no metrics in this window"), "{f}");
3882 assert!(app.names.is_empty() && app.series.is_empty(), "{f}");
3883 }
3884
3885 #[test]
3891 fn a_pane_whose_data_never_arrived_draws_nothing_rather_than_something_else() {
3892 let mut app = App::new(Source::Local("/nonexistent".into()));
3893 app.job = None;
3894 app.stats = "0/0 blocks".into();
3895 for (mode, header) in [
3896 (Mode::Frame, " frame "),
3897 (Mode::Map, " map "),
3898 (Mode::Diag, " node "),
3899 (Mode::Trace, " trace "),
3900 ] {
3901 app.mode = mode;
3902 let f = app.frame(W, H);
3903 assert_eq!(f.len(), H, "{mode:?} still owes the terminal every row");
3904 let text = strip(&f.join("\n"));
3905 assert!(!text.contains(header), "{mode:?} drew a header: {text}");
3906 }
3907 app.mode = Mode::Detail;
3911 let text = strip(&app.frame(W, H).join("\n"));
3912 assert!(text.contains("nothing selected"), "{text}");
3913 }
3914
3915 #[test]
3924 fn the_disk_line_is_coloured_by_how_close_the_node_is_to_full() {
3925 let mut app = App::new(Source::Local("/nonexistent".into()));
3926 for (doc, style, text) in [
3927 (r#"{"free_fraction":0.0}"#, term::RED, "disk 0% free"),
3928 (r#"{"free_fraction":0.07}"#, term::RED, "disk 7% free"),
3929 (r#"{"free_fraction":0.1}"#, term::YELLOW, "disk 10% free"),
3930 (r#"{"free_fraction":0.15}"#, term::YELLOW, "disk 15% free"),
3931 (r#"{"free_fraction":0.2}"#, term::DIM, "disk 20% free"),
3932 (r#"{"free_fraction":0.9}"#, term::DIM, "disk 90% free"),
3933 (r#"{"free_fraction":null}"#, term::DIM, "disk unreadable"),
3937 ] {
3938 app.diag = Some(crate::api::parse(doc).unwrap());
3939 let head = app.diag_pane(W).remove(0);
3940 assert!(strip(&head).contains(text), "{head:?}");
3941 assert!(head.contains(&format!("{style}{text}")), "{head:?}");
3942 }
3943 }
3944
3945 #[test]
3950 fn degraded_syncs_appear_only_once_there_are_some() {
3951 let mut app = App::new(Source::Local("/nonexistent".into()));
3952 for (doc, want) in [
3953 (r#"{"degraded_syncs":0}"#, None),
3954 (r#"{}"#, None),
3955 (r#"{"degraded_syncs":1}"#, Some("degraded syncs 1")),
3956 (r#"{"degraded_syncs":41000}"#, Some("degraded syncs 41.0k")),
3957 ] {
3958 app.diag = Some(crate::api::parse(doc).unwrap());
3959 let head = app.diag_pane(W).remove(0);
3960 match want {
3961 Some(text) => {
3962 assert!(strip(&head).contains(text), "{head:?}");
3963 assert!(
3964 head.contains(&format!("{} · {text}", term::YELLOW)),
3965 "{head:?}"
3966 );
3967 }
3968 None => assert!(!strip(&head).contains("degraded"), "{head:?}"),
3969 }
3970 }
3971 }
3972
3973 #[test]
3977 fn a_series_with_no_points_says_so_rather_than_drawing_a_flat_line() {
3978 let mut app = App::new(Source::Local("/nonexistent".into()));
3979 app.series = array(
3980 &crate::api::parse(
3981 r#"{"series":[{"name":"m","attributes":{"pod":"a"},"points":[],
3982 "exemplars":[]}]}"#,
3983 )
3984 .unwrap()["series"],
3985 );
3986 let lines = app.series_lines(60);
3987 let chart = strip(&lines[1]);
3988 assert!(chart.contains("no points"), "{chart:?}");
3989 assert!(!chart.contains('▁') && !chart.contains('█'), "{chart:?}");
3990 assert_eq!(lines.len(), 3);
3993 assert!(strip(&lines[0]).contains("pod=a"), "{:?}", lines[0]);
3994 }
3995
3996 #[test]
4001 fn a_failed_span_is_red_in_the_list_and_the_waterfall_unless_it_is_selected() {
4002 let rows = array(
4003 &crate::api::parse(
4004 r#"{"rows":[
4005 {"span_id":"01","name":"GET /ok","start_time_unix_nano":"10",
4006 "duration_nano":"1000000","status_code":1},
4007 {"span_id":"02","parent_span_id":"01","name":"POST /pay",
4008 "start_time_unix_nano":"20","duration_nano":"2000000","status_code":2}]}"#,
4009 )
4010 .unwrap()["rows"],
4011 );
4012 let (ok, bad) = (&rows[0], &rows[1]);
4013
4014 let line = span_row(bad, W, false, 2_000_000);
4015 assert!(line.contains(&format!("{}POST", term::RED)), "{line:?}");
4016 assert!(strip(&line).contains("ERROR"), "{line:?}");
4017 let sel = span_row(bad, W, true, 2_000_000);
4018 assert!(!sel.contains(term::RED), "{sel:?}");
4019 assert!(strip(&sel).contains("ERROR"), "{sel:?}");
4020 let fine = span_row(ok, W, false, 2_000_000);
4021 assert!(!fine.contains(term::RED), "{fine:?}");
4022 assert!(!strip(&fine).contains("ERROR"), "{fine:?}");
4023
4024 let t = Trace::new("ab".into(), &rows);
4028 let ends = [16, 24, W - 11, W];
4029 let bar = span_bar(bad, 1, false, ends, &t);
4030 assert!(bar.starts_with(term::RED), "{bar:?}");
4031 assert!(strip(&bar).contains("POST /pay"), "{bar:?}");
4032 assert!(bar.contains(&format!("{}█", term::RED)), "{bar:?}");
4033 let bar = span_bar(ok, 0, false, ends, &t);
4034 assert!(!bar.contains(term::RED), "{bar:?}");
4035 assert!(bar.contains(&format!("{}█", term::GREEN)), "{bar:?}");
4036 }
4037
4038 #[test]
4043 fn an_events_own_attributes_are_indented_under_it_in_the_detail_pane() {
4044 let row = crate::api::parse(
4045 r#"{"body":"boom","attributes":{"service.name":"checkout"},
4046 "events":[{"name":"exception","time_unix_nano":"10",
4047 "attributes":{"exception.type":"IOError"}}],
4048 "links":[{"trace_id":"abab","attributes":{"rel":"follows"}}]}"#,
4049 )
4050 .unwrap();
4051 let text = detail(&row, 80)
4052 .iter()
4053 .map(|l| strip(l))
4054 .collect::<Vec<_>>()
4055 .join("\n");
4056
4057 assert!(text.contains("exception.type"), "{text}");
4058 assert!(text.contains("IOError"), "{text}");
4059 assert!(text.contains(" rel "), "{text}");
4062 assert!(text.contains("follows"), "{text}");
4063 assert!(!text.contains("{exception.type"), "{text}");
4066 }
4067
4068 #[test]
4075 fn an_unbound_key_changes_nothing_and_each_pane_moves_its_own_cursor() {
4076 let dir = store("keys");
4077 let mut app = App::new(Source::Local(dir));
4078 settle(&mut app);
4079
4080 press(&mut app, Key::Char('h'));
4083 assert_eq!(app.tab, Tab::Metrics, "h off the left of the ring wraps");
4084 press(&mut app, Key::Char('l'));
4085 assert_eq!(app.tab, Tab::Logs, "and l off the right wraps back");
4086
4087 let before = strip(&app.frame(W, H).join("\n"));
4089 for k in [Key::BackTab, Key::Char('z'), Key::Char('#')] {
4090 let f = strip(&press(&mut app, k));
4091 assert_eq!(f, before, "{k:?} changed the screen");
4092 }
4093
4094 press(&mut app, Key::Char('?'));
4096 assert_eq!(app.mode, Mode::Help);
4097 press(&mut app, Key::Enter);
4098 assert_eq!(app.mode, Mode::Help, "enter in help opens nothing");
4099 press(&mut app, Key::Esc);
4100
4101 press(&mut app, Key::Char('/'));
4104 typed(&mut app, "abc");
4105 press(&mut app, Key::Up);
4106 press(&mut app, Key::PageDown);
4107 assert_eq!(app.filter, "abc");
4108 assert_eq!(app.mode, Mode::Filter);
4109 press(&mut app, Key::Esc);
4110 assert!(app.filter.is_empty());
4111
4112 press(&mut app, Key::Enter);
4116 assert_eq!(app.mode, Mode::Detail);
4117 assert!(!app.key(Key::Ctrl('c'), H), "^C quits from a detail pane");
4118 press(&mut app, Key::Esc);
4119
4120 press(&mut app, Key::Char('3'));
4123 assert!(!app.on_series);
4124 press(&mut app, Key::Char('j'));
4125 assert_eq!(app.nsel, 1, "the names list moves first");
4126 app.series = array(
4129 &crate::api::parse(
4130 r#"{"series":[{"name":"m","attributes":{"pod":"a"},"points":[["1","1"]]},
4131 {"name":"m","attributes":{"pod":"b"},"points":[["1","2"]]}]}"#,
4132 )
4133 .unwrap()["series"],
4134 );
4135 press(&mut app, Key::Tab);
4136 assert!(app.on_series);
4137 press(&mut app, Key::Char('j'));
4138 assert_eq!((app.nsel, app.ssel), (1, 1), "the series cursor moved");
4139 press(&mut app, Key::Char('G'));
4140 assert_eq!(app.ssel, 1, "and clamps at the last series");
4141
4142 press(&mut app, Key::Char('1'));
4146 app.rows = array(
4147 &crate::api::parse(
4148 r#"{"rows":[{"trace_id":"cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd","body":"x"}]}"#,
4149 )
4150 .unwrap()["rows"],
4151 );
4152 let f = strip(&press(&mut app, Key::Char('t')));
4153 assert!(app.err, "{f}");
4154 assert!(f.contains("no spans found for trace cdcd"), "{f}");
4155 assert_eq!(app.mode, Mode::List, "and the waterfall does not open");
4156 }
4157
4158 #[test]
4165 fn the_alert_pane_moves_its_own_cursor_and_says_when_there_are_no_rules() {
4166 const ROWS: &str = r#"{"rows":[],"stats":{"blocks_total":0,"blocks_scanned":0,
4167 "rows_scanned":0,"rows_matched":0}}"#;
4168 const TWO: &str = r#"{"alerts":[
4169 {"name":"first","state":"firing","severity":"critical","metric":"count","op":">",
4170 "threshold":1.0,"value":9.0,"matched":9,"total":null,"over_nano":"60000000000",
4171 "for_nano":"0","since":"1","firing_since":"1","evaluated_at":"2","signal":"traces",
4172 "filter":"field:status_code=2","link":"","error":null},
4173 {"name":"second","state":"ok","severity":"warning","metric":"count","op":">=",
4174 "threshold":1.0,"value":0.0,"matched":0,"total":null,"over_nano":"300000000000",
4175 "for_nano":"0","since":null,"firing_since":null,"evaluated_at":"2",
4176 "signal":"logs","filter":"attr:pod=a","link":"","error":null}],
4177 "every_nano":"5000000000"}"#;
4178
4179 let addr = source::serve(
4182 [ROWS, TWO, ROWS, r#"{"alerts":[]}"#]
4183 .map(source::ok)
4184 .to_vec(),
4185 );
4186 let mut app = App::new(Source::Remote(addr));
4187 settle(&mut app);
4188
4189 press(&mut app, Key::Char('a'));
4190 assert_eq!(app.mode, Mode::Alerts);
4191 let f = press(&mut app, Key::Char('j'));
4192 assert_eq!(app.psel, 1, "the pane owns the cursor, not the record list");
4193 let row = f
4196 .lines()
4197 .find(|l| strip(l).contains("second"))
4198 .unwrap_or_default();
4199 assert!(row.contains(term::REV), "{row:?}");
4200
4201 press(&mut app, Key::Enter);
4204 assert_eq!(app.tab, Tab::Logs, "the second rule is a logs rule");
4205 assert_eq!(app.filter, "attr:pod=a");
4206 assert_eq!(app.mode, Mode::List);
4207
4208 let f = strip(&press(&mut app, Key::Char('a')));
4210 assert_eq!(app.mode, Mode::Alerts, "{f}");
4211 assert!(f.contains("0 rules") && f.contains("0 firing"), "{f}");
4212 assert!(f.contains("evaluates no rules"), "{f}");
4213 press(&mut app, Key::Enter);
4216 assert_eq!(app.mode, Mode::Alerts, "enter on no rule opens nothing");
4217 assert_eq!((app.tab, app.filter.as_str()), (Tab::Logs, "attr:pod=a"));
4218 }
4219
4220 const RUN_SELF: &str = "tui::tests::the_event_loop_follows_a_growing_store_and_quits_on_q";
4222
4223 #[test]
4239 fn the_event_loop_follows_a_growing_store_and_quits_on_q() {
4240 if let Some(dir) = std::env::var_os("MIRA_TUI_PTY_DIR") {
4241 return run(Source::Local(dir.into())).unwrap();
4245 }
4246 let dir = store("pty");
4247 let ahead = crate::api::now_nanos() as u64 + 2_000_000_000;
4248 let mut b = mira_core::logs::LogsBuilder::new();
4249 b.append_request(&crate::e2e::logs_export("mira-tail-tick", ahead, 1))
4250 .unwrap();
4251 let node = mira_core::block::node_id("b");
4252 mira_core::block::publish(&dir, "logs", node, 0, 0, &b.finish().unwrap()).unwrap();
4253
4254 let crate::term::tests::Pty {
4255 screen,
4256 err,
4257 stalled,
4258 trailing,
4259 } = crate::term::tests::drive(
4260 RUN_SELF,
4261 &[("MIRA_TUI_PTY_DIR", dir.as_os_str())],
4262 &[
4263 ("checkout handled request 4", b"f"),
4265 ("● follow", b""),
4267 ("mira-tail-tick handled request 0", b"q"),
4270 ],
4271 "\x1b[?25h\x1b[?1049l",
4272 );
4273 assert!(
4274 stalled.is_none(),
4275 "child stalled at {stalled:?}: {err}\nscreen:\n{screen:?}"
4276 );
4277 assert!(err.is_empty(), "{err}");
4281 assert!(screen.contains("1 passed"), "{screen:?}");
4282 assert!(trailing, "terminal not restored: {screen:?}");
4283 let tick = screen.find("mira-tail-tick").unwrap();
4286 let old = screen.rfind("checkout handled request 4").unwrap();
4287 assert!(tick < old, "the tick replaced the window: {screen:?}");
4288 }
4289}