Skip to main content

mira/
tui.rs

1//! The terminal UI.
2//!
3//! Same four questions the browser UI asks, over the same three routes, in the
4//! place the person asking them already is. The browser UI wins on waterfalls
5//! and charts; this wins on being one `kubectl exec` away and on working with
6//! no server running at all — [`Source::Local`] reads a block directory
7//! in-process, so a detached volume is still readable after the pod that wrote
8//! it is gone.
9//!
10//! Everything here is synchronous. There is no runtime, no task and no channel:
11//! the loop draws, blocks on a key, and runs one query. A query that takes two
12//! seconds freezes the UI for two seconds — which is why the frame is painted
13//! *before* the query runs, so the freeze always has a "running" on it rather
14//! than a stale screen.
15
16mod 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
27/// Selectable query windows, coarse on purpose: the point of `[` and `]` is to
28/// change the answer in one keystroke, and a continuous control would need two.
29const 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        // Deferred so the frame above — the one that says what is running — is
39        // on screen before the query blocks the thread that would have drawn it.
40        if let Some(job) = app.job.take() {
41            app.run(job, h);
42            continue;
43        }
44        // Follow mode is the read timing out rather than a thread or a channel:
45        // the whole UI is one loop, and a key that does not arrive within the
46        // interval is exactly the signal to re-run the query.
47        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            // A tick, not a reload: `reload` writes "running" over the status
53            // bar, and a bar that strobes every three seconds is worse than no
54            // bar at all.
55            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    /// Where a filter word with no operator goes.
77    ///
78    /// Someone who types `refused` means "show me the ones that say refused",
79    /// which is what every log viewer in the market does with a bare word and
80    /// what this one used to do with it: nothing at all, silently, while the
81    /// filter bar showed the word and the rows looked filtered.
82    ///
83    /// `None` for metrics, whose `where` terms are attribute predicates on data
84    /// points — there is no text column to search, so a bare word there is
85    /// reported rather than invented a meaning for.
86    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    /// Columns of the signal's root table.
95    ///
96    /// This is what decides whether `name=checkout` filters a column or an
97    /// attribute, and getting it wrong is silent both ways: an unknown `field`
98    /// matches nothing rather than erroring, and a column missing from this
99    /// list is sent as `attr`, which the Bloom filter prunes to zero rows. That
100    /// is what happened to `event_name`, added to the schema after this list
101    /// was written — so `tests::the_field_list_is_the_schema` now pins the two
102    /// together. Not an intra-doc link: the target is behind `cfg(test)`, so it
103    /// does not exist in the configuration rustdoc builds.
104    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    /// One span of the waterfall, in the same renderer [`Mode::Detail`] uses.
145    ///
146    /// A separate mode rather than a flag on `Detail` because the two differ in
147    /// where they came from and where Esc goes back to, and because the span is
148    /// not in `self.rows` — see [`App::selected`].
149    Span,
150    /// The frame around whatever the filter matched (section 7.3), from `/correlate`.
151    Frame,
152    /// The service map, from `/map`.
153    Map,
154    /// The rules this node evaluates and what they are doing, from `/alerts`.
155    Alerts,
156    /// What the node counts about itself, from `/stats`.
157    Diag,
158    Help,
159}
160
161impl Mode {
162    /// Whether this pane scrolls a rendered body rather than selecting a row.
163    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
179/// What Enter does to the selected line of the frame or map pane.
180enum Pick {
181    Service(String),
182    Trace(String),
183}
184
185/// How the map names the synthetic caller of every root span
186/// (`mira_core::frame::ENTRY`). A string where every other node key is a decimal
187/// entity key, so it cannot collide with one.
188const ENTRY_KEY: &str = "entry";
189
190struct App {
191    src: Source,
192    tab: Tab,
193    win: usize,
194    limit: usize,
195    filter: String,
196    /// The filter as it was before `/` was pressed, so Esc can put it back.
197    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    /// Which metrics pane the arrow keys drive.
208    on_series: bool,
209
210    trace: Option<Trace>,
211    /// The frame and map panes, and the one cursor they share — only one of the
212    /// two is ever on screen, and both are lists of things Enter acts on.
213    frame: Option<FrameView>,
214    map: Option<MapView>,
215    /// The alert list shares the same cursor: it is the third pane that is a
216    /// list of things Enter acts on, and only one of the three is ever drawn.
217    alerts: Vec<Yaml>,
218    /// The whole `/api/v1/stats` document, rendered rather than destructured —
219    /// a field added to the endpoint should appear here without a code change.
220    diag: Option<Yaml>,
221    psel: usize,
222    /// Re-run the list query on a timer instead of blocking on a key.
223    tail: bool,
224    scroll: usize,
225    stats: String,
226    status: String,
227    err: bool,
228    job: Option<Job>,
229}
230
231/// How long a follow tick waits for a key before giving up and re-querying.
232///
233/// The same three seconds the browser UI polls on, and for the same reason: a
234/// read here is single-digit milliseconds, so the interval is chosen for how
235/// often a person wants the screen to change, not for what the engine can take.
236const TAIL_MS: i32 = 3000;
237
238/// What `/correlate` came back with, flattened into the two lists it draws.
239///
240/// Services are grouped by name because an entity is an *instance* (section 7.2):
241/// three replicas of one service are three entities, which matters to the
242/// engine and not to someone reading a strip of names.
243struct FrameView {
244    from: i64,
245    to: i64,
246    truncated: bool,
247    services: Vec<(String, usize)>,
248    traces: Vec<String>,
249}
250
251/// The service map as a call tree rather than a graph.
252///
253/// A terminal draws a tree well and a graph badly, and the tree is the reading
254/// that answers the question anyway: what calls what, and where does it start.
255/// One line per node, so every line is something Enter can act on — the same
256/// shape as the waterfall, which is the other tree in this UI.
257struct MapView {
258    rows: Vec<(usize, Yaml)>,
259    unresolved: i64,
260}
261
262struct Trace {
263    id: String,
264    /// Spans in waterfall order — depth-first from each root, children by start
265    /// time — paired with their indent depth.
266    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    // ---- queries ----------------------------------------------------------
305
306    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            // Neither answers about blocks, so the scan counters would all read
338            // zero and look like a query that found nothing.
339            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                // A follow tick must not yank the cursor back to the top:
352                // someone watching a stream is usually reading one row while
353                // the rest of them move under it.
354                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                    // One name selected is one chart the user did not have to
372                    // ask for; the metrics tab is useless until a series loads.
373                    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                    // Not "everything is healthy". A node with no rules file
409                    // pages nobody, and that is the one thing this pane must not
410                    // let a reader assume.
411                    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                    // Not an error: a store with logs and no traces is a
424                    // perfectly ordinary store, and the map is built from
425                    // `parent_span_id` at read time.
426                    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    /// The same filter the list is showing, expanded into a frame.
454    ///
455    /// `traces` then `peers`, in that order and not the other: `traces` measures
456    /// the real extent of what was found, and `peers` then reads the services
457    /// inside it. Reversed, `peers` runs against the window the rows happened to
458    /// land in and finds only what is already on screen.
459    ///
460    /// No `limit`: a frame is bounded by the engine's own entity and trace caps
461    /// and says so in `truncated`, which is a different question from how many
462    /// rows this pane can draw.
463    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            // ponytail: 64 series is three screenfuls at three lines each, and
510            // the engine does not report `max_series` truncation the way it
511            // reports `max_points`, so the 65th is invisible here. Give it a
512            // badge when the response grows a count to put in one.
513            j.key("max_series");
514            j.i64(64);
515            // A sparkline is one character per point, so the API's default of
516            // 5 000 would download three orders of magnitude more than the
517            // widest terminal can render. Not scaled to the terminal either:
518            // `spark` buckets by the column's peak, so points past the column
519            // count still decide whether a spike shows, and 400 is enough of
520            // them for any width. What the cap costs — it keeps the newest
521            // 400, not a thinned 400 — is on screen as `+n dropped`.
522            j.key("max_points");
523            j.i64(400);
524            j.key("where");
525            self.terms(j);
526        });
527        j.into_string()
528    }
529
530    /// A word in the filter that this tab has nowhere to put.
531    ///
532    /// Only metrics can produce one — every other tab reads a bare word as free
533    /// text. The one thing that must not happen is silence: the filter bar goes
534    /// on showing the word, so the rows look filtered when they are not.
535    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                    // A bare word is free text over the tab's message column.
553                    // Quoted, so `scalar` cannot decide that `500` was a number
554                    // and hand `body` an integer to compare against.
555                    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    // ---- keys -------------------------------------------------------------
580
581    /// Handle one key. `false` means quit.
582    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            // Back one step, not back to the list: a span detail was opened
590            // from the waterfall and that is where its reader still is.
591            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                // The waterfall renders a span's shape; its attributes and its
657                // status message only exist in the detail view, and without
658                // this the selected span had no way to reach one.
659                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            // Both answer for the node rather than for the data, so neither
683            // depends on the tab, the filter or the window — and both are
684            // reachable from wherever the reader already is.
685            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            // Follow, in the `tail -f` sense. Only the record list: the metrics
694            // tab reloads its name list and reselects, and a pane that
695            // reselects under the reader every three seconds is unusable.
696            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    /// Enter, in the frame or map pane.
714    ///
715    /// Everything in either pane is a link back into an ordinary query, which is
716    /// what the algebra's closure buys (section 7.3): there is nothing selectable here
717    /// that lands the reader somewhere they cannot then filter.
718    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            // Anded onto the filter rather than replacing it: the frame is a
725            // narrowing step, and whatever is already typed is the reason this
726            // frame exists.
727            Some(Pick::Service(name)) => {
728                let t = service_term(&name);
729                // Selecting the same service twice is a double-press, not a
730                // request for the term twice.
731                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    /// Enter, on an alert: show the records the rule counted.
745    ///
746    /// The rule's `where` terms come back from the API already spelled in the
747    /// filter-bar grammar (`alert::filter_of`), so this is an assignment rather
748    /// than a translation — the alert and the query it fired on cannot drift
749    /// into two different filters, because there is only one spelling of them.
750    /// The window is left alone: `over` is how the rule counts, and a reader who
751    /// has just been paged usually wants more history than that, not less.
752    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    /// What the frame or map cursor is pointing at.
766    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                // `entry` is synthetic — the caller of every root span — so
781                // there is no service behind it to filter on.
782                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    /// Move whichever cursor the current pane owns.
825    ///
826    /// One function rather than one per view because every view's list is a
827    /// selected index plus a length, and the clamping is the part that is easy
828    /// to get wrong twice.
829    ///
830    /// Clamped in `usize`, not `isize`. The scrolling panes report their length
831    /// as `usize::MAX` — see [`cursor`](App::cursor) — and that is a negative
832    /// `isize`, so an `isize` clamp reads its own upper bound as below its lower
833    /// bound and panics. `j` in the detail pane is the keystroke that did it.
834    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            // The detail and help panes scroll rather than select, but the
847            // arithmetic is the same and the bound is the line count, which the
848            // renderer knows and this does not — so let it run to the end and
849            // let `frame` clamp.
850            (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    /// Follow whatever trace the selection points at.
880    ///
881    /// This is the correlation story as one keystroke: a log line carries the
882    /// `trace_id` of the request that emitted it, a span carries its own, and a
883    /// metric exemplar carries the id of the request that produced the
884    /// measurement. Three different tabs, one key, because to the person
885    /// looking it is the same question.
886    fn open_trace(&mut self) {
887        let id = match (self.mode, self.tab) {
888            // Already inside the trace this would open.
889            (Mode::Trace | Mode::Span, _) => return,
890            // These panes have their own selection, and the record list's is
891            // behind them — following that one would open a trace nobody is
892            // pointing at.
893            (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            // The waterfall's own selection, which is not in `self.rows` and
923            // usually cannot be: a followed trace is queried over all of
924            // retention, so its spans are rarely the rows the list tab holds.
925            (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    // ---- rendering --------------------------------------------------------
936
937    fn frame(&mut self, w: usize, h: usize) -> Vec<String> {
938        // A terminal narrower than this cannot show a timestamp and a body, and
939        // every column computation below starts clamping to zero. Drawing the
940        // frame at 40 anyway is worse than not drawing it: `Term::draw` has no
941        // cursor addressing, so every over-wide row wraps and the top of the
942        // frame scrolls off for good, with nothing on screen saying why.
943        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        // Scrolling panes hand back every line they have and are windowed here,
967        // so each one does not have to reimplement the clamp.
968        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        // The window and limit are reserved before anything else is written, so
1007        // an 80-column terminal clips the filter rather than the two fields that
1008        // say what the filter was applied to.
1009        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                    // A block where the cursor would be. The real cursor is
1033                    // hidden for the whole session, so drawing one is cheaper
1034                    // than showing and positioning it every frame.
1035                    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    /// Logs and traces: a list on top, the selection's detail below.
1089    fn records(&self, w: usize, h: usize) -> Vec<String> {
1090        // Two thirds to the list. Below about a quarter the detail pane shows
1091        // nothing useful, and above about a half the list stops being a list.
1092        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    /// Metric names on the left, the selected name's series on the right.
1152    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                // Already styled and already padded to `chart_w` by `done`.
1179                r.raw(line, chart_w);
1180            }
1181            out.push(r.done());
1182        }
1183        out
1184    }
1185
1186    /// Three lines per series: its attributes, its sparkline, its exemplars.
1187    ///
1188    /// Pre-rendered as plain strings and then windowed by the caller, because
1189    /// the vertical scroll is over series and each one is a fixed three rows.
1190    fn series_lines(&self, w: usize) -> Vec<String> {
1191        if self.series.is_empty() {
1192            // Through a `Row` even when it is one word, so the caller's promise
1193            // that every line here is exactly `w` wide holds for this one too.
1194            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                    // `max_points` truncation is newest-wins, so a capped
1231                    // sparkline is the tail of the window drawn under a filter
1232                    // bar that still says `last 24h`. Its width is taken out of
1233                    // the bar rather than appended, because `Row` clips at the
1234                    // edge and the one thing that must not be clipped is the
1235                    // line saying the picture is incomplete. The browser legend
1236                    // carries the same badge, for the same reason.
1237                    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    /// The frame: the window it measured, the services in it, the traces in it.
1271    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            // A capped frame is a sample, and a sample read as a census is the
1290            // one way this pane can be confidently wrong.
1291            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                // Three replicas of one service are three entities and one
1306                // name. The count is the only thing on screen that says so.
1307                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    /// The service map, as a call tree rooted at `entry`.
1330    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            // Every edge on screen is a lower bound without this: an unresolved
1340            // span is one whose parent was not in the sample, so its call was
1341            // never counted against anything.
1342            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    /// Every rule this node evaluates, firing or not.
1362    ///
1363    /// Quiet rules are listed as prominently as loud ones. An alerting screen
1364    /// that shows only what is on fire cannot answer the question an operator
1365    /// actually has at 3am — "is the rule I wrote for this even running?" — and
1366    /// an empty screen then means both "all clear" and "nothing is watching".
1367    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        // Two lines each, so the selected rule's predicate is on screen without
1385        // a keystroke: the filter *is* the explanation of the number.
1386        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                // An unevaluated rule is not a quiet one, and the pane says
1426                // which it is: the state above reads "ok" either way.
1427                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    /// What this node counts about itself: `/api/v1/stats`, laid out.
1452    ///
1453    /// Every number here is one the ingest path already keeps or the filesystem
1454    /// already knows, so opening this pane costs a `readdir` and nothing else —
1455    /// a diagnostics screen that perturbs what it measures is worse than none.
1456    /// `mmap` residency is deliberately absent: `mincore` over a week of blocks
1457    /// is thousands of syscalls per refresh, and the page cache is the OS's to
1458    /// report. ponytail: add it per-block behind an explicit key if a hit rate
1459    /// ever turns out to be the question someone is actually asking.
1460    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        // The one number on this screen that is a reason to wake someone up.
1476        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        // Only when it has happened. On every volume Mira is meant to run on
1489        // this is zero, and a permanent "degraded syncs 0" would train the eye
1490        // to skip the line on the one node where it is not.
1491        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                        // Absent is "the filesystem would not answer", which is
1532                        // not zero blocks — see the endpoint's own comment.
1533                        None => "?".into(),
1534                    },
1535                    tally(i64_of(&s["blocks_published"]) as f64)
1536                ),
1537            ));
1538            // Bytes per row on disk is the cost-per-GB axis (section 11) measured on
1539            // this node's own data rather than on a benchmark corpus: Arrow
1540            // encoding, dictionary sharing and zstd, all of it, in one number.
1541            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            // Stalled is the readiness condition, so it is the one line here
1569            // that is never printed as a zero and never left off when set.
1570            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        // Where each column ends: the tree, the service, the bar, the duration.
1601        // Ends rather than widths because every write is "fill up to here", and
1602        // the last one is `w`, so the row always adds up to the frame.
1603        let namew = (w / 3).clamp(16, 46);
1604        let svcw = (w / 6).clamp(8, 20);
1605        // `run` never draws below 40 columns, which leaves the bar five cells
1606        // once the name, service and duration have taken theirs.
1607        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        // Scroll so the selected span stays on screen. Its line number is not
1637        // its index — events and links push it down — so it is counted rather
1638        // than assumed.
1639        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        // The engine returns entities sorted by key, which is a hash — so a
1666        // name-ordered list is the only one that reads the same twice.
1667        services.sort_by(|a, b| a.0.cmp(&b.0));
1668        FrameView {
1669            // `from`/`to` are int64 and so arrive as strings (section 7.6).
1670            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    /// Depth-first from `entry`, children by descending call volume.
1690    ///
1691    /// Volume order rather than name order because the first thing anyone wants
1692    /// out of a service map is the hot path, and it should be the first thing
1693    /// they read. A service reachable by two paths is drawn under the first one
1694    /// reached and not again: a tree with the same subtree in it twice is a tree
1695    /// nobody can count spans off.
1696    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        // `entry` is synthetic and so is not in `nodes`, but the tree has to
1700        // start somewhere and a map that hides where traffic arrives cannot be
1701        // read. No `spans` or `errors`: it is a caller, not a service.
1702        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                    // An edge naming a node the sample never reached. Skipping
1728                    // it loses nothing: its own spans are not in `nodes` either.
1729                    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            // Reversed onto a stack, so the busiest child comes off first.
1739            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        // A cycle, or a service whose only callers were outside the window.
1746        // Appended flat rather than dropped: a map that silently omits a node
1747        // reads as "this service is idle".
1748        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        // Depth-first from each root, children ordered by start time — the
1774        // order a waterfall is read in. A span whose parent is not in the set
1775        // (sampled away, or emitted by a service whose data went elsewhere) is
1776        // a root too, otherwise it would be dropped from a view whose whole job
1777        // is to show everything about one request.
1778        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        // A parent cycle would drop spans rather than loop, but it must not
1806        // silently lose them: anything unvisited goes on the end flat.
1807        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
1832// ---- row renderers --------------------------------------------------------
1833
1834fn 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    // A bar against the widest span in the result, so the outliers in a page of
1884    // results are visible without opening any of them.
1885    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)]
1892/// One span's row: name, service, bar, duration, each clipped to its column.
1893///
1894/// `ends` is the column boundaries from [`App::waterfall`]. Every write caps the
1895/// row at its own end before writing, so an over-long span name eats into its
1896/// own column and nothing else — without the cap it would push the bar right and
1897/// shove the duration off the screen.
1898fn 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    // Both offset and length come from the same scale, so a zero-duration span
1920    // still gets one cell and lands in the right place rather than vanishing.
1921    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
1935/// The selected record, field by field, then its attributes.
1936fn 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
2056// ---- small helpers --------------------------------------------------------
2057
2058fn body_h(h: usize) -> usize {
2059    h.saturating_sub(4).max(1)
2060}
2061
2062/// First visible index of a list scrolled to keep `sel` on screen.
2063fn 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
2085/// A mapping's entries as `(key, rendered value)`, in the order they arrived.
2086fn 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
2096/// One value on one line.
2097///
2098/// Nested values are rendered inline rather than summarised as `[2 items]`: an
2099/// array attribute, a kvlist attribute and a structured body are decoded by the
2100/// engine at some cost, and the detail pane is the one place the reader asked to
2101/// see them. `Row` clips the line at the pane width, so this only has to be
2102/// compact, not fitted.
2103///
2104/// ponytail: the whole value is built and then clipped, so a thousand-element
2105/// array costs a string nobody sees, once per frame. Depth is capped because
2106/// nesting is what makes that unbounded; cap the element count too if a payload
2107/// that wide ever turns up.
2108fn 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                // A non-string key cannot happen in decoded OTLP, but rendering
2129                // it beats dropping the pair it belongs to.
2130                .map(|(k, v)| format!("{}: {}", nested(k, 0), nested(v, depth - 1)))
2131                .collect();
2132            format!("{{{}}}", items.join(", "))
2133        }
2134        _ => String::new(),
2135    }
2136}
2137
2138/// A 64-bit integer out of a response, whichever way it was encoded.
2139///
2140/// The API renders an int64 as a JSON *string* (section 7.6) — `from`, `to`, `avg_nano`
2141/// — because 1.7e18 does not survive a double, and a counter small enough to be
2142/// safe is rendered as a number. Both shapes reach here.
2143fn i64_of(y: &Yaml) -> i64 {
2144    y.as_i64()
2145        .or_else(|| y.as_str()?.parse().ok())
2146        .unwrap_or_default()
2147}
2148
2149/// The filter term that selects one service, for a name picked out of the frame
2150/// or map pane.
2151///
2152/// Always quoted, because [`tokens`] splits on whitespace and a service name is
2153/// free to contain some. A name carrying a double quote has no equality
2154/// spelling at all — the tokeniser toggles on one and there is no escape — so it
2155/// degrades to a `contains` on the part before it, which is a true term rather
2156/// than one that mis-parses into a different filter.
2157fn 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
2164/// One service in the map: where it sits in the call tree, and what it did.
2165fn 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    // The three count columns are 44 wide together, and they are the point of
2170    // the pane — the name yields to them rather than pushing `avg` off the edge
2171    // at 80 columns.
2172    let namew = w.saturating_sub(44).clamp(16, 40);
2173
2174    let mut r = Row::new(namew);
2175    // Two columns a level, capped so a deep chain eats its own column rather
2176    // than pushing the counts off the right-hand edge.
2177    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            // The dash sits in the count column and the word is dropped, so a
2195            // clean service lines its zero up under the counts above it.
2196            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        // An integer point crosses the wire quoted (section 7.6, `Json::i64_str`), so
2215        // a counter arrives here as a string and a double does not. Refusing
2216        // the string draws "no points" over a series that has plenty.
2217        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
2253/// Local wall-clock time of a nanosecond timestamp.
2254///
2255/// `localtime_r` rather than a date crate: `libc` is already here, and the only
2256/// hard part of civil time — the zone — is exactly the part a hand-rolled
2257/// version would get wrong.
2258fn 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    // SAFETY: `tm` is integers plus a `tm_zone` pointer, and null is a valid
2283    // value for a raw pointer, so all-zero is a valid `tm`. It has to be: the
2284    // return value is discarded below, and `localtime_r` returns NULL without
2285    // writing anything for a `time_t` it cannot represent. The zeroed struct
2286    // then renders as 1900-01-01 — a wrong timestamp, not uninitialised memory.
2287    let mut tm: libc::tm = unsafe { std::mem::zeroed() };
2288    // SAFETY: both arguments are live, correctly typed locals. The `_r` form
2289    // writes only into `tm` and returns no pointer into shared state, so unlike
2290    // `localtime` nothing here can be clobbered by another thread's call.
2291    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
2316/// A number at human precision: metric values span counters in the millions and
2317/// ratios below one, and neither reads well under the other's format.
2318fn 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
2328/// One `label   value` line of the diagnostics pane.
2329fn 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
2336/// An age in seconds, at the precision someone reading it cares about.
2337///
2338/// Not [`dur`]: that one formats a span *inside* a request, where the
2339/// interesting range is nanoseconds to seconds. This one formats how long a
2340/// process or a block has been around, where it is seconds to days — and 187
2341/// minutes is not a readable way to say three hours.
2342fn 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
2352/// Binary units, because every other tool an operator has open uses them.
2353fn 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
2368/// The right-hand side of an alert row: where the number sits against the line.
2369fn 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
2386/// A record count. [`g`] is for metric values, where 5 means "about five" and a
2387/// decimal is information; here it means five records and a decimal is noise.
2388fn 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
2405/// Eight levels of block, scaled between the run's own min and max.
2406///
2407/// Relative rather than absolute because a flat series at 8 191 and a flat
2408/// series at 3 are the same shape, and the shape is what a sparkline is for —
2409/// the numbers are printed beside it.
2410fn 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    // More points than columns: average each column's bucket rather than
2421    // sampling one of them, so a spike between two samples is not invisible.
2422    (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        // Deliberately wider than the view's own window. A trace reached from a
2442        // metric exemplar or an old log line is frequently outside it, and
2443        // `trace_id = <hex>` is the one filter with a Bloom sidecar behind it —
2444        // this opens one block out of all of retention, not all of them.
2445        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/// One piece of a filter line: a `key op value`, or a word with no operator.
2475///
2476/// A bare word is kept rather than discarded because only the caller knows what
2477/// it should mean — see [`Tab::free_text`].
2478#[derive(Debug, PartialEq)]
2479enum Part {
2480    Term(String, &'static str, String),
2481    Word(String),
2482}
2483
2484/// Split the filter line into query terms.
2485///
2486/// `service.name=checkout severity_number>=17 body~"connection refused"`. It is
2487/// deliberately not the API's KYAML grammar: that one is for programs, and no
2488/// one types `{"attr":"service.name","eq":"checkout"}` into a filter box. Both
2489/// end up as the same [`Term`](mira_core::query::Term) either way.
2490fn parse_filter(s: &str) -> Vec<Part> {
2491    let mut out = Vec::new();
2492    for tok in tokens(s) {
2493        // Earliest operator wins, longest at that position — otherwise `>=`
2494        // parses as `>` with a value of `=17`.
2495        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            // A half-written term (`k=`, `=v`) is dropped: the operator says
2506            // what was meant and it is not there yet.
2507            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
2519/// Whitespace-separated, except inside double quotes.
2520fn 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
2539/// Type a filter value the way its text reads.
2540///
2541/// Ids stay strings whatever they look like: a 16-hex-digit span id made
2542/// entirely of decimal digits parses as an integer, and comparing a
2543/// `FixedSizeBinary` column against one matches nothing at all — silently,
2544/// because an inapplicable term is defined to return no rows rather than an
2545/// error.
2546fn 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        // `!=` must not be read as `=` with a key ending in `!`.
2581        assert_eq!(parse_filter("k!=v"), vec![term("k", "ne", "v")]);
2582        // A word with no operator survives parsing; what it means is the tab's
2583        // business, not this function's.
2584        assert_eq!(
2585            parse_filter("justawordse"),
2586            vec![Part::Word("justawordse".into())]
2587        );
2588        // A half-written term is still dropped: the operator says what was
2589        // meant, and it is not there yet.
2590        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    /// The finding this fixes: a word with no operator used to be dropped on
2605    /// the floor while the filter bar went on displaying it, so the rows looked
2606    /// filtered and were not.
2607    #[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        // On traces the message column is the span name, not the body.
2620        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        // A word that reads as a number stays text: `body` is a string column
2625        // and an integer there would match nothing, silently.
2626        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    /// Metrics has no message column, so the word is named in the status line
2633    /// instead of silently doing nothing.
2634    #[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        // The rest of the line still applies — the word is dropped from the
2642        // query, not the whole filter.
2643        let q = crate::api::parse_series(&app.series_query(), 0).unwrap();
2644        assert_eq!(q.terms.len(), 1);
2645
2646        // The word is looked for past the terms, not just at the front: a
2647        // search that stopped at the first part would report `pod=a`, which is
2648        // the one term that *was* applied.
2649        app.filter = "pod=a refused".into();
2650        assert_eq!(app.ignored_word().as_deref(), Some("refused"));
2651
2652        // And no tab with a message column ever reports one.
2653        for tab in [Tab::Logs, Tab::Traces] {
2654            app.tab = tab;
2655            assert_eq!(app.ignored_word(), None);
2656        }
2657    }
2658
2659    /// The filter box has to produce a document the API's own parser accepts,
2660    /// and has to route each term to `field` or `attr` correctly — an `attr`
2661    /// term against a root column matches nothing, silently.
2662    #[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        // Not a root column of `spans`, so it has to be an attribute.
2678        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        // An id stays a string even though this one is all hex.
2683        assert!(matches!(&q.terms[2].value, Value::Str(s) if s.len() == 32));
2684
2685        // The other two spellings a typed value arrives in. An attribute
2686        // compared against the *string* `"true"` matches nothing at all — OTLP
2687        // decodes a bool attribute to a bool — and `0.5` truncated to an
2688        // integer would make `>=0.5` mean `>=0`.
2689        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        // ...and an id made entirely of digits is still an id.
2694        assert!(matches!(&q.terms[2].value, Value::Str(_)));
2695    }
2696
2697    /// The same key is a field on one signal and an attribute on another, and
2698    /// the tab is the only thing that knows which.
2699    #[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    /// Every root column the engine can compare has to be in `fields`, or the
2712    /// filter box sends it as `attr` and the Bloom filter prunes it to nothing.
2713    /// Pinned against the schema so the next column added there cannot repeat
2714    /// what happened to `event_name`.
2715    ///
2716    /// The browser UI keeps a second copy of the same list and, being
2717    /// JavaScript, cannot read `schema.rs` — so it is pinned here too rather
2718    /// than against literals of its own. `include_str!` is what makes that
2719    /// work: the file is a compile-time input, so adding a column to the
2720    /// schema fails this test until *both* filter boxes know about it.
2721    #[test]
2722    fn the_field_list_is_the_schema() {
2723        // Left out on purpose: `id`, `resource_id` and `scope_id` are
2724        // block-local numbers that mean nothing to whoever is typing, and
2725        // `body_ser` is Binary, which `field_pred` has no comparison for.
2726        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            // Every odd field of a split on `'` is a quoted element, which is
2740            // enough parsing for a list of bare identifiers.
2741            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    /// An array or kvlist attribute is decoded by the engine at real cost, and
2759    /// the detail pane is where the reader asked to see it — `[2 items]` there
2760    /// throws the answer away.
2761    #[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        // Capped, so a pathological nest cannot spend a frame building a line
2768        // that gets clipped at the pane width anyway.
2769        assert_eq!(v(r#"{"v":[[[[["deep"]]]]]}"#), "[[[[[1 items]]]]]");
2770        // Every scalar an OTLP attribute can decode to, each printed as itself.
2771        assert_eq!(v(r#"{"v":[1.5,true,null]}"#), "[1.5, true, null]");
2772        // A hash at the cap is summarised by size, the same as an array is.
2773        assert_eq!(v(r#"{"v":[[[[{"a":1,"b":2}]]]]}"#), "[[[[{2 keys}]]]]");
2774        // A key the response does not carry at all. The loader answers
2775        // `BadValue`, and this pane prints what it is handed — so the empty
2776        // string is what keeps `BadValue` off the screen.
2777        assert_eq!(v("{}"), "");
2778    }
2779
2780    /// The number formatters, each at the boundary it exists for.
2781    ///
2782    /// They are pure and tiny, and every one of them is a place where a wrong
2783    /// unit reads as a right answer — `1.83` where `1.83 ms` was meant is the
2784    /// whole story of a diagnostics pane nobody trusts.
2785    #[test]
2786    fn each_formatter_covers_the_range_it_was_written_for() {
2787        // `dur` measures a span inside a request: nanoseconds to minutes.
2788        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        // `since` measures how long something has been around: seconds to days.
2795        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        // A metric value: counters in the millions and ratios below one share
2805        // one column, so neither may be printed in the other's format.
2806        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        // Past the last unit there is nothing left to divide by, and the answer
2816        // is a big number rather than a panic.
2817        assert_eq!(bytes(4096.0 * 1024f64.powi(4)), "4096.0 TiB");
2818
2819        // The OTLP `SpanKind` enum. Zero and out-of-range are the same answer,
2820        // because the spec's own zero *is* unspecified.
2821        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        // Severity bands are the OTLP numbers, not a guess at `severity_text`.
2836        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    /// A waterfall is read parent-then-children, and the child order is by
2853    /// start time. Reconstructing that from `parent_span_id` is the only real
2854    /// logic in the trace view.
2855    #[test]
2856    fn spans_order_depth_first_with_orphans_kept() {
2857        let doc = crate::api::parse(
2858            // Quoted, as the server writes them (section 7.6).
2859            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 starts before 02, and 04 hangs off 03.
2879                ("03", 1),
2880                ("04", 2),
2881                ("02", 1),
2882                // Parent never arrived; it is still part of the trace.
2883                ("09", 0),
2884            ]
2885        );
2886        assert_eq!(t.t0, 10);
2887        assert_eq!(t.span_ns, 100);
2888    }
2889
2890    /// A cyclic `parent_span_id` is corrupt data, not a reason to hang or to
2891    /// quietly show fewer spans than the trace has.
2892    #[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        // Flat is flat, not a division by zero.
2912        assert_eq!(spark(&[5.0; 4], 4), "▁▁▁▁");
2913        assert_eq!(spark(&[], 8), "");
2914        // More points than columns: each column takes its bucket's peak, so the
2915        // spike survives.
2916        assert!(spark(&[0.0, 0.0, 9.0, 0.0], 2).ends_with('█'));
2917    }
2918
2919    /// Every 64-bit field crosses the wire as a JSON string (section 7.6), and
2920    /// `Yaml::as_i64` says `None` to a string. Each `unwrap_or(0)` behind one
2921    /// of those reads is therefore a pane that renders — clock at the epoch,
2922    /// bars of zero width, `no points` over a full series — rather than a pane
2923    /// that fails, which is why this is asserted against the wire spelling and
2924    /// not against the shape a fixture finds convenient.
2925    #[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        // Not 00:00:00 in UTC nor 01:00:00 in CET — the epoch under either.
2935        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        // A bar drawn against a scale it is the whole of is a full bar.
2940        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        // An integer point is quoted; a double is not. Both chart.
2945        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    /// `max_points` truncation keeps the newest points, so a capped sparkline
2959    /// is the tail of the window drawn under a filter bar that still says
2960    /// `last 24h`. The engine reports the count; the only failure is not
2961    /// showing it.
2962    #[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            // A full sparkline and a wide range, so the badge is the field
2967            // that would be clipped if its width were not reserved.
2968            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        // Reserved out of the bar rather than appended past the edge: `Row`
2985        // clips at the right margin, and this is the field that must not be
2986        // the one it clips.
2987        assert!(capped.trim_end().ends_with("+8240 dropped"), "{capped:?}");
2988
2989        // And no badge at all when the whole window fitted, rather than a `+0`.
2990        app.series = series("");
2991        let whole = strip(&app.series_lines(80)[1]);
2992        assert!(!whole.contains("dropped"), "{whole:?}");
2993    }
2994
2995    /// Every pane clamps its own cursor, and an empty result is the case that
2996    /// gets it wrong.
2997    #[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        // The scrolling panes report `usize::MAX` for their length, which is a
3012        // negative `isize`. Clamping there used to panic on the first `j`.
3013        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    /// The map is a graph on the wire and a tree on screen, and every way that
3024    /// flattening can go wrong loses a service silently.
3025    #[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        // Busiest branch first, because the hot path is what a map is opened
3046        // for. `entry` is on top even though it is not one of the nodes.
3047        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        // A retry loop. Drawn once, at the depth it was first reached — a tree
3067        // that re-expands a cycle does not terminate.
3068        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        // A service whose only callers were outside the window is unreachable
3078        // from `entry`. It goes on the end flat rather than vanishing: a map
3079        // that omits a node reads as "this service is idle".
3080        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        // The span budget can cut a sample mid-trace, so an edge can name a
3090        // node that is not in `nodes` at all.
3091        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        // Nothing at all: `Job::Map` reports it rather than opening the pane,
3098        // and the header's `len() - 1` must not underflow if it ever does.
3099        assert!(map(&[], &[]).rows.is_empty());
3100    }
3101
3102    /// What the two new panes do with Enter. Everything either pane offers has
3103    /// to land back in an ordinary query — that is the point of the algebra's
3104    /// closure, and a selection that goes nowhere is the way to lose it.
3105    #[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        // Two entity keys, one name: an entity is an instance (section 7.2), and the
3122        // count is the only thing that says a service has replicas. Sorted by
3123        // name, because the engine sorts by key and a key is a hash.
3124        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        // Past the services, the same cursor indexes the traces.
3132        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        // And `t` follows the pane's own selection, not the list behind it.
3137        app.job = None;
3138        app.open_trace();
3139        assert!(matches!(app.job, Some(Job::Trace(_))));
3140
3141        // A service is anded onto the filter and hands the reader back to the
3142        // list, so the next thing they do is an ordinary query.
3143        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        // Twice is a double-press.
3149        app.mode = Mode::Frame;
3150        app.follow_pick();
3151        assert_eq!(app.filter, r#"body~timeout service.name="api""#);
3152
3153        // `entry` is the synthetic caller of every root span, so there is no
3154        // service behind it to filter on and Enter must do nothing.
3155        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        // Only those two panes have a pick at all. The record list's cursor is
3162        // still set behind them, and answering with *that* row's service would
3163        // make Enter act on something nobody is pointing at.
3164        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        // The filter box splits on whitespace and toggles on `"`, so a name
3170        // carrying either has to survive the round trip into a real term.
3171        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        // No equality spelling for an embedded quote; the prefix is a true
3177        // term. `parse_filter` trims it, so it is `say` rather than `say `.
3178        assert_eq!(
3179            parse_filter(&service_term(r#"say "hi""#)),
3180            [term("service.name", "contains", "say")]
3181        );
3182    }
3183
3184    /// The two node panes, from the keystroke to the pixels.
3185    ///
3186    /// A layout test proves nothing here: `Row` clips at `max` rather than
3187    /// overflowing, so a pane that has silently lost its numbers still passes
3188    /// `every_view_fits_the_frame_at_any_size` with the right line count. This
3189    /// one reads the numbers back off the screen.
3190    #[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        // One reply per connection, in the order this session asks: the first
3213        // list query, `a`, the requery Enter fires, then `d`.
3214        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        // The ratio reads as a percentage against its threshold, with the two
3223        // counts behind it — the whole of why the rule fired, on one line.
3224        assert!(a.contains("50.00% > 5.00%"), "{a}");
3225        assert!(a.contains("5 of 10"), "{a}");
3226        // A count rule has no denominator and must not invent one.
3227        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        // Enter lands on the rows the rule counted: its signal's tab, its terms
3236        // in the filter box, and a query already in flight.
3237        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        // The cost-per-GB axis on this node's own data: 158 MB over 1.204M rows.
3257        assert!(d.contains("150.7 MiB") && d.contains("131 B/row"), "{d}");
3258        assert!(d.contains("3 shed") && d.contains("1 refused"), "{d}");
3259        // Stalled is the readiness condition; it is never left off when set.
3260        assert!(d.contains("cannot store this signal"), "{d}");
3261        // A signal the document did not mention is skipped, not drawn empty —
3262        // its section rule is what is absent, not the word, which is also a tab.
3263        assert!(!d.contains("── metrics") && !d.contains("── traces"), "{d}");
3264    }
3265
3266    /// Both panes report a process. A directory is not one, and saying so is
3267    /// better than an empty screen that looks like "no rules, all healthy".
3268    #[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    /// Painting must never panic and never overrun, whatever the terminal size
3281    /// or the mode — a panic here leaves the user's shell in raw mode.
3282    #[test]
3283    fn every_view_fits_the_frame_at_any_size() {
3284        let doc = crate::api::parse(
3285            // Every 64-bit field is quoted, because that is how it arrives:
3286            // `query.rs` writes `Int64`, `UInt64` and `Timestamp` through
3287            // `Json::i64_str` (section 7.6). A fixture that spells them bare tests a
3288            // response the server never sends.
3289            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        // The diagnostics pane renders the stats document as it arrives, so the
3327        // fixture is one: absent counters are `null` there, never zero.
3328        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        // Under the minimum there is no frame to fit. Drawing the 40-column one
3393        // anyway wraps every row — `Term::draw` has no cursor addressing — and
3394        // scrolls its own top off the screen for good, so it says so instead,
3395        // at the width the terminal actually has.
3396        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    /// A block directory with all three signals in it, timestamped now, because
3411    /// every query the TUI issues is relative to the clock.
3412    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        // The same trace id `logs_export` stamps on its records, so `t` on a log
3437        // line has somewhere to go.
3438        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                            // A tree, not a list: the waterfall's indenting and
3448                            // its parent lookup are the point of the view.
3449                            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    /// One turn of the loop in [`run`], minus the terminal.
3514    ///
3515    /// The order matters and is the reason it is a helper rather than a call to
3516    /// `key`: the frame is painted *before* the deferred job runs, so the screen
3517    /// that says "running" is on it while the query blocks. Anything asserting on
3518    /// what the user sees has to go through the same sequence.
3519    fn settle(app: &mut App) -> String {
3520        let mut f = app.frame(W, H);
3521        // Bounded so a job that queues itself fails the test instead of hanging
3522        // it. Eight is three more than the longest real sequence.
3523        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    /// The whole app driven by keystrokes against a real block directory, which
3564    /// is what `mira mira --data-dir` does with no server anywhere. `run` itself
3565    /// is not here — it is this loop plus a `Term`, and a `Term` needs a pty.
3566    #[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        // Opening screen: the logs tab, already loaded, with no key pressed.
3572        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        // Detail, scrolled, and back. Esc leaves the mode, it does not quit.
3578        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        // `l` walks right through the tabs; each arrival reloads.
3587        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        // Metrics loads names, then loads the first series without being asked —
3593        // the two-job sequence `settle` exists to drain.
3594        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        // Tab moves the arrow keys from the name list to the series list.
3600        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        // A metric exemplar carries the trace id of the request that produced
3610        // the measurement, and `t` follows it. Three tabs, one key.
3611        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        // Indented: the waterfall is a tree, not a list.
3619        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        // `t` inside a trace is a no-op rather than a reload of the same trace.
3624        press(&mut app, Key::Char('t'));
3625        assert_eq!(app.mode, Mode::Trace);
3626
3627        // Enter opens the selected span. The waterfall draws a span's shape;
3628        // its attributes and its status message exist nowhere else, and the
3629        // span is not in `self.rows` — this trace came from an exemplar.
3630        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        // Back to the waterfall it was opened from, not to the list.
3637        press(&mut app, Key::Esc);
3638        assert_eq!(app.mode, Mode::Trace);
3639        // `q` leaves the mode. Only `q` on the list quits.
3640        press(&mut app, Key::Char('q'));
3641        assert_eq!(app.mode, Mode::List);
3642
3643        // Help is a toggle, and Esc closes it too.
3644        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    /// The filter bar: typed, edited, applied, and undone. Applying it is the
3654    /// only thing here that costs a query, which is why Esc restores the text
3655    /// rather than re-running with the old one.
3656    #[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        // Esc restores the text that was there before `/`, so an abandoned edit
3679        // leaves the view exactly as it was found.
3680        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        // A bare word on the metrics tab has no text column to search, so it is
3692        // reported rather than quietly dropped.
3693        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    /// The controls that change the query rather than the view, and the two
3701    /// things that can go wrong: a selection that points at nothing to follow,
3702    /// and a store that cannot be read.
3703    #[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        // Paging and the jump keys share one clamp across every pane.
3729        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        // A span row has a trace id; a row that does not exist has nothing.
3739        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        // The store going away under the session is a message on the status
3745        // bar, not a panic and not a blank screen.
3746        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    /// The two panes built out of the frame algebra, reached the way a reader
3757    /// reaches them.
3758    ///
3759    /// `every_line_of_the_frame_and_map_leads_back_into_a_query` drives `pick`
3760    /// against a fixture, which proves what Enter means but not that either key
3761    /// sends a document the engine answers. This one presses the keys against a
3762    /// real block directory, so the query is the one `/api/v1/correlate` and
3763    /// `/api/v1/map` actually run.
3764    #[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        // Metrics is not an anchor: a frame widens out from records and a
3771        // series is not one. Said, rather than sent and returned empty.
3772        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        // `h` walks the tabs the way `l` does not, and a digit jumps.
3779        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        // The pane owns its own cursor, and it clamps like every other one.
3794        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        // No services on the frame: `peers` reports who the anchor set talks
3807        // *to*, and this corpus is one service talking to itself. So the whole
3808        // pane is its traces, and Enter on one opens the waterfall.
3809        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        // The map runs over the same window and does have a service in it: it
3816        // is built from `parent_span_id` at read time, so one service calling
3817        // itself is still an edge.
3818        let f = strip(&press(&mut app, Key::Char('m')));
3819        assert_eq!(app.mode, Mode::Map);
3820        assert!(f.contains("map"), "{f}");
3821
3822        // The cursor starts on `entry`, the synthetic caller of every root
3823        // span. There is no service behind it and no trace under it, so both
3824        // keys that act on a selection have to decline rather than act on the
3825        // row below or on the record list behind the pane.
3826        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        // Off `entry`, which is synthetic, and onto the service under it.
3839        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        // Follow belongs to the record list, and the bar says when it is on.
3845        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        // Nothing matched. The frame does not open on an empty answer: an empty
3857        // frame drawn as a frame reads as "these are all the services".
3858        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        // The map answers for the window and not for the filter — `/api/v1/map`
3865        // takes no `where` — so the only way to empty it is an empty store. Not
3866        // an error: a directory with logs and no spans is an ordinary one.
3867        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        // And the metrics tab over the same empty store. An empty name list is
3876        // not an error either, but it has to say which of the two it is: the
3877        // pane otherwise sits blank next to a chart area, which reads as a
3878        // metric that failed to load rather than a window with no metrics in it.
3879        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    /// A mode outlives the data behind it: the query that would have filled the
3886    /// pane failed, or the pane was open when the store went away. The frame
3887    /// draws whatever mode is set, so each pane checks its own `Option` — and
3888    /// the failure this prevents is the previous pane's rows drawn under the
3889    /// new pane's header, which is a screenful of numbers about something else.
3890    #[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        // The detail pane says so instead of drawing nothing, because it is the
3908        // one pane opened *on* a selection — blank there reads as a record with
3909        // no fields rather than as no record.
3910        app.mode = Mode::Detail;
3911        let text = strip(&app.frame(W, H).join("\n"));
3912        assert!(text.contains("nothing selected"), "{text}");
3913    }
3914
3915    /// The one number on the node pane that is a reason to wake someone up, and
3916    /// the colour is the whole of the signal: a node with 7% of its disk left
3917    /// drawn in the same grey as one with 90% is a page nobody makes.
3918    ///
3919    /// Both boundaries are in the table, because `<` and `<=` are one keystroke
3920    /// apart and only a value sitting exactly on 0.1 or 0.2 tells them apart.
3921    /// Zero is there because a full disk is the case the colour exists for, and
3922    /// it is the one an operator sees at the worst possible moment.
3923    #[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            // Absent is neither full nor empty: `statfs` would not answer, and
3934            // a percentage invented for that case is the one number here that
3935            // must never be made up.
3936            (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    /// A volume with no write barrier is a durability fact, and the header is
3946    /// where a node's durability facts live. It is absent at zero on purpose:
3947    /// a line that reads "0" on every healthy node is a line the eye learns to
3948    /// skip, which is exactly the wrong training for the one node it matters on.
3949    #[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    /// A series the engine answered with no points in the window. An empty
3974    /// sparkline and a flat one at zero are the same picture and opposite
3975    /// facts — nothing reported, versus reported as nothing.
3976    #[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        // The three-line shape holds anyway, or the caller's `ssel * 3` window
3991        // lands on the wrong series.
3992        assert_eq!(lines.len(), 3);
3993        assert!(strip(&lines[0]).contains("pod=a"), "{:?}", lines[0]);
3994    }
3995
3996    /// A failed span is red in both places it is drawn — and is not, under the
3997    /// cursor, because red on reverse-video is the one combination that
3998    /// disappears on a light terminal. The badge stays either way: colour is
3999    /// how the eye finds the row, the word is how the reader confirms it.
4000    #[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        // The waterfall, whose selection is its own and whose bar takes the
4025        // same colour as the name — a green bar on a failed span would be the
4026        // pane contradicting itself.
4027        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    /// An event's own attributes are the payload of the event — an exception's
4039    /// type and stacktrace live nowhere else — and the detail pane is the only
4040    /// screen that ever shows them. Rendering the event and dropping them
4041    /// throws away the reason the reader opened the record.
4042    #[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        // Indented one level further than the event's own fields, so a reader
4060        // can tell whose attribute it is.
4061        assert!(text.contains("    rel "), "{text}");
4062        assert!(text.contains("follows"), "{text}");
4063        // And rendered as lines rather than as one inline map next to the key,
4064        // which is what the top-level `attributes` skip is there to prevent.
4065        assert!(!text.contains("{exception.type"), "{text}");
4066    }
4067
4068    /// Two lists, one set of arrow keys, and the keys that do nothing at all.
4069    ///
4070    /// Every `_ => {}` in the key handler exists so a stray keystroke is not a
4071    /// quit, a reload or a mode change — the arms around each one all have side
4072    /// effects. The cursor arms are the other half: the metrics tab keeps two
4073    /// indices, and moving the wrong one scrolls a pane nobody is looking at.
4074    #[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        // The tab ring closes in both directions; these are the two arms the
4081        // walk in `the_frame_and_map_panes_open_from_a_keystroke` never reaches.
4082        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        // A key with no binding leaves the screen byte for byte as it was.
4088        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        // Enter in a pane with nothing to open is one of them.
4095        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        // In the filter box, so is every key that is neither text nor an edit:
4102        // an arrow key typed into the filter would be a query for `\x1b[A`.
4103        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        // `q` backs out one pane at a time and only quits from the list. ^C
4113        // quits from wherever the reader is, which is the whole difference
4114        // between them, and the pane it is pressed in must not swallow it.
4115        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        // The metrics tab: `tab` decides which of its two lists the arrows
4121        // drive, and each keeps its own index while the other one holds still.
4122        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        // Two series under one name, because this corpus has one apiece and a
4127        // cursor with nowhere to go proves nothing about which one moved.
4128        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        // A record whose trace id is not in this store. Only the store can say
4143        // so, and it says it on the status bar rather than opening an empty
4144        // waterfall that reads as a trace with no spans in it.
4145        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    /// The alert pane's own cursor, and the two answers that are not a list of
4159    /// rules: a node with no rules file, and Enter pressed on nothing.
4160    ///
4161    /// An empty pane must not read as "all clear" — a node evaluating no rules
4162    /// pages nobody, and that is the one thing an alerting screen cannot let a
4163    /// reader assume.
4164    #[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        // One reply per connection, in the order this session asks for them:
4180        // the opening list query, `a`, the requery Enter fires, `a` again.
4181        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        // Read back off the screen: the highlight is on the second rule, which
4194        // is what Enter is about to act on.
4195        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        // Enter follows the *selected* rule: its signal picks the tab and its
4202        // filter is already spelled in the filter bar's own grammar.
4203        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        // A node with no rules file at all.
4209        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        // ...where Enter has nothing under the cursor to follow, and must leave
4214        // the tab and the filter exactly as the reader left them.
4215        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    /// The name of the test below, as `--exact` wants it.
4221    const RUN_SELF: &str = "tui::tests::the_event_loop_follows_a_growing_store_and_quits_on_q";
4222
4223    /// [`run`] itself: `mira mira --data-dir`, on a real terminal, following a
4224    /// block directory that grows under it.
4225    ///
4226    /// Everything else in this file tests the app without the loop, because a
4227    /// `Term` needs a pty. This is the loop: draw *before* the deferred query so
4228    /// the "running" frame is on screen while the query blocks, a poll that
4229    /// times out into a re-query rather than a reload, and `q` returning from
4230    /// `run` rather than exiting the process — which is what leaves the `Term`
4231    /// to be dropped and the user's terminal to be handed back.
4232    ///
4233    /// Follow mode is proved by data rather than by the indicator: the parent
4234    /// publishes a block stamped two seconds into the future, so `to: now`
4235    /// excludes it from the opening query *and* from the reload `f` fires, and
4236    /// only a tick that ran later can put it on screen. A loop that never timed
4237    /// out would sit on the older frame until `q`.
4238    #[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            // The child. It ends by returning from `run`, not by panicking:
4242            // the exit path is part of what is under test, and the harness
4243            // line it then prints to the pty is what the parent reads it off.
4244            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                // The opening screen — drawn, queried, drawn again — then `f`.
4264                ("checkout handled request 4", b"f"),
4265                // The reload it fires, which still cannot see the future block.
4266                ("● follow", b""),
4267                // ...and the tick three seconds later, which can. `q` from the
4268                // list is the quit.
4269                ("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        // `q` returned from `run`; it did not `exit`, panic or die of a signal.
4278        // The harness line is printed after the alternate screen was already
4279        // handed back, which is why it is on the pty at all.
4280        assert!(err.is_empty(), "{err}");
4281        assert!(screen.contains("1 passed"), "{screen:?}");
4282        assert!(trailing, "terminal not restored: {screen:?}");
4283        // The screen the reader would have been looking at: the tick's row is
4284        // above the rows that were already there, not instead of them.
4285        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}