Skip to main content

taba_cli/tui/screens/topics/monitor/
view.rs

1//! The drawing of the charts of a queue: three line charts stacked on one time axis, each with its legend and the
2//! current value of every line in its title.
3
4use ratatui::layout::{Constraint, Layout, Rect};
5use ratatui::symbols::Marker;
6use ratatui::text::{Line, Span};
7use ratatui::widgets::{Axis, Block, Chart, Dataset, GraphType, Paragraph, Wrap};
8use ratatui::Frame;
9
10use crate::tui::api::Severity;
11use crate::tui::history::Point;
12use crate::tui::i18n::{self, Lang};
13use crate::tui::model::{Model, TopicMonitor, TopicTab};
14use crate::tui::screens::cluster::view::error_text;
15use crate::tui::screens::cluster::Unit;
16use crate::tui::screens::topics::messages;
17use crate::tui::screens::topics::monitor::{
18    line_points, lines_of, window_ms, ChartDef, LineDef, CHARTS,
19};
20
21/// A value as a chart shows it: a count, a rate per second or an age.
22pub fn fmt(lang: Lang, unit: Unit, value: f64) -> String {
23    match unit {
24        Unit::Count | Unit::Bytes => i18n::group(lang, value.round().max(0.0) as u64),
25        Unit::PerSecond => format!("{}/s", i18n::decimal(lang, value, 1)),
26        Unit::Seconds => i18n::duration(value.max(0.0) as u64),
27    }
28}
29
30/// The line above the view: where the person is (`Topics › orders · Charts`) and, when the reading is not good,
31/// its state.
32pub fn head_line(m: &Model, mon: &TopicMonitor) -> Line<'static> {
33    let tx = m.tx();
34    let th = m.theme;
35    let key = if mon.tab == TopicTab::Messages {
36        "topic.screen-messages"
37    } else {
38        "topic.screen-charts"
39    };
40    let mut spans = vec![Span::styled(tx.tw(key, &[("name", &mon.name)]), th.title())];
41    if let Some(fail) = &mon.feed.error {
42        let text = if mon.feed.data.is_some() || !mon.series.is_empty() {
43            tx.t("state.stale")
44        } else {
45            error_text(m, fail)
46        };
47        spans.push(Span::styled(
48            format!("  {} {text}", th.icon(Severity::Warning)),
49            th.warning(),
50        ));
51    }
52    Line::from(spans)
53}
54
55/// The charts of a queue as in the charts of the cluster: the list of the three indicators on the left, with the
56/// current figure of each, the one under the cursor drawn beside it, and `Enter` to give it the whole screen.
57pub fn draw(f: &mut Frame, area: Rect, m: &Model) {
58    let Some(mon) = &m.topics.monitor else { return };
59    if mon.tab == TopicTab::Messages {
60        return messages::view::draw(f, area, m, mon);
61    }
62    let tx = m.tx();
63    let th = m.theme;
64    let [head, main, note] = Layout::vertical([
65        Constraint::Length(1),
66        Constraint::Min(7),
67        Constraint::Length(2),
68    ])
69    .areas(area);
70
71    f.render_widget(Paragraph::new(head_line(m, mon)), head);
72
73    let chart = mon.monitor.chart.min(CHARTS.len() - 1);
74    if mon.monitor.full {
75        draw_chart(f, main, m, mon, &CHARTS[chart]);
76    } else {
77        let width = if main.width >= 100 { 40 } else { 28 };
78        let [list_area, chart_area] =
79            Layout::horizontal([Constraint::Length(width), Constraint::Min(20)]).areas(main);
80        draw_chart_list(f, list_area, m, mon, chart);
81        draw_chart(f, chart_area, m, mon, &CHARTS[chart]);
82    }
83
84    // What the charts honestly show (TUI-MON-05) and their state.
85    let at = mon.monitor.frozen.unwrap_or(m.now);
86    let window = window_ms(m, mon);
87    let collected = mon
88        .series
89        .values()
90        .map(|ring| ring.collected_ms(at))
91        .max()
92        .unwrap_or(0);
93    let mut text = if collected < window {
94        tx.tw(
95            "chart.collected",
96            &[("time", &i18n::duration(collected / 1000))],
97        )
98    } else {
99        tx.t("chart.history-note")
100    };
101    if mon.monitor.frozen.is_some() {
102        text.push_str(&format!(" · {}", tx.t("chart.paused")));
103    }
104    text.push_str(&format!(
105        " · {}",
106        tx.t(if mon.monitor.fixed_scale {
107            "chart.scale-fixed"
108        } else {
109            "chart.scale-auto"
110        })
111    ));
112    let partial = mon
113        .series
114        .keys()
115        .any(|key| line_points(m, mon, key).iter().any(|p| p.partial));
116    if partial {
117        text.push_str(&format!(" · {}", tx.t("chart.partial-samples")));
118    }
119    f.render_widget(
120        Paragraph::new(text)
121            .style(th.dim())
122            .wrap(Wrap { trim: false }),
123        note,
124    );
125}
126
127/// The list of the indicators: the name and the current figure of the first line of each.
128fn draw_chart_list(f: &mut Frame, area: Rect, m: &Model, mon: &TopicMonitor, selected: usize) {
129    let tx = m.tx();
130    let th = m.theme;
131    let inner_width = usize::from(area.width.saturating_sub(2));
132    let lines: Vec<Line> = CHARTS
133        .iter()
134        .enumerate()
135        .map(|(i, def)| {
136            let marker = if i == selected { th.selector() } else { "  " };
137            let value = def
138                .lines
139                .first()
140                .and_then(|(key, _)| line_points(m, mon, key).last().map(|p| p.value))
141                .map_or_else(|| "—".to_owned(), |v| fmt(m.lang, def.unit, v));
142            let room = inner_width.saturating_sub(value.chars().count() + 1);
143            let name: String = format!("{marker}{}", tx.t(def.title))
144                .chars()
145                .take(room)
146                .collect();
147            let pad = inner_width
148                .saturating_sub(name.chars().count() + value.chars().count())
149                .max(1);
150            let style = if i == selected {
151                th.selected()
152            } else {
153                ratatui::style::Style::default()
154            };
155            Line::from(vec![
156                Span::styled(format!("{name}{}", " ".repeat(pad)), style),
157                Span::styled(value, if i == selected { style } else { th.dim() }),
158            ])
159        })
160        .collect();
161    f.render_widget(
162        Paragraph::new(lines).block(
163            Block::bordered()
164                .title(format!(" {} ", tx.t("chart.list")))
165                .border_style(th.accent()),
166        ),
167        area,
168    );
169}
170
171/// The vertical bounds: the window's own range with some air, or from zero to the largest value of the window.
172fn bounds(fixed: bool, values: &[f64]) -> (f64, f64) {
173    let high = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
174    if fixed {
175        return (
176            0.0,
177            if high.is_finite() && high > 0.0 {
178                high
179            } else {
180                1.0
181            },
182        );
183    }
184    let low = values.iter().copied().fold(f64::INFINITY, f64::min);
185    let pad = if (high - low).abs() < f64::EPSILON {
186        high.abs().max(1.0) * 0.1
187    } else {
188        (high - low) * 0.1
189    };
190    ((low - pad).max(0.0), high + pad)
191}
192
193/// One chart as a narrow cell of the strip under the list: the title carries only the markers and the current values.
194pub fn draw_compact_chart(
195    f: &mut Frame,
196    area: Rect,
197    m: &Model,
198    mon: &TopicMonitor,
199    def: &ChartDef,
200) {
201    draw_chart_in(f, area, m, mon, def, true);
202}
203
204fn draw_chart(f: &mut Frame, area: Rect, m: &Model, mon: &TopicMonitor, def: &ChartDef) {
205    draw_chart_in(f, area, m, mon, def, false);
206}
207
208fn draw_chart_in(
209    f: &mut Frame,
210    area: Rect,
211    m: &Model,
212    mon: &TopicMonitor,
213    def: &ChartDef,
214    compact: bool,
215) {
216    let tx = m.tx();
217    let th = m.theme;
218    let lang = m.lang;
219    let lines: Vec<(LineDef, Vec<Point>)> = lines_of(m, mon, def)
220        .into_iter()
221        .map(|line| {
222            let points = line_points(m, mon, &line.key);
223            (line, points)
224        })
225        .collect();
226    // The title carries the legend: a marker and a color for each line, and what it is now (TUI-MON-02).
227    let mut title = vec![Span::raw(format!(" {} ", tx.t(def.title)))];
228    for (i, (line, points)) in lines.iter().enumerate() {
229        let now = points
230            .last()
231            .map_or_else(|| "—".to_owned(), |p| fmt(lang, def.unit, p.value));
232        let text = if compact {
233            format!(" {} {now}", th.marker(i))
234        } else {
235            format!(" {} {} {now} ", th.marker(i), line.label)
236        };
237        title.push(Span::styled(text, th.series(i)));
238    }
239    let block = Block::bordered().title(Line::from(title));
240    let values: Vec<f64> = lines
241        .iter()
242        .flat_map(|(_, points)| points.iter().map(|p| p.value))
243        .collect();
244    if values.is_empty() {
245        let inner = area.inner(ratatui::layout::Margin::new(2, 1));
246        f.render_widget(block, area);
247        f.render_widget(Paragraph::new(tx.t("chart.no-data")).style(th.dim()), inner);
248        return;
249    }
250    let at = mon.monitor.frozen.unwrap_or(m.now);
251    let window_s = window_ms(m, mon) as f64 / 1000.0;
252    let xy: Vec<Vec<(f64, f64)>> = lines
253        .iter()
254        .map(|(_, points)| {
255            points
256                .iter()
257                .map(|p| ((p.at as f64 - at as f64) / 1000.0, p.value))
258                .collect()
259        })
260        .collect();
261    let (low, high) = bounds(mon.monitor.fixed_scale, &values);
262    let mid = (low + high) / 2.0;
263    let datasets: Vec<Dataset> = xy
264        .iter()
265        .enumerate()
266        .filter(|(_, points)| !points.is_empty())
267        .map(|(i, points)| {
268            Dataset::default()
269                .marker(if th.ascii {
270                    Marker::Dot
271                } else {
272                    Marker::Braille
273                })
274                .graph_type(GraphType::Line)
275                .style(th.series(i))
276                .data(points)
277        })
278        .collect();
279    let chart = Chart::new(datasets)
280        .legend_position(None)
281        .block(block)
282        .x_axis(
283            Axis::default()
284                .bounds([-window_s, 0.0])
285                .labels(vec![
286                    format!("-{}", i18n::duration(window_s as u64)),
287                    tx.t("chart.now"),
288                ])
289                .style(th.dim()),
290        )
291        .y_axis(
292            Axis::default()
293                .bounds([low, high])
294                .labels(vec![
295                    fmt(lang, def.unit, low),
296                    fmt(lang, def.unit, mid),
297                    fmt(lang, def.unit, high),
298                ])
299                .style(th.dim()),
300        );
301    f.render_widget(chart, area);
302}