Skip to main content

taba_cli/tui/screens/cluster/
view.rs

1//! The drawing of the Cluster tab: the list of conditions and nodes, the detail of a node and its charts.
2
3use ratatui::layout::{Constraint, Layout, Rect};
4use ratatui::style::{Modifier, Style};
5use ratatui::symbols::Marker;
6use ratatui::text::{Line, Span};
7use ratatui::widgets::{
8    Axis, Block, Cell, Chart, Dataset, GraphType, Paragraph, Row as TableRow, Table, Wrap,
9};
10use ratatui::Frame;
11
12use crate::tui::api::{NodeStatus, Severity};
13use crate::tui::history;
14use crate::tui::i18n::{self, Lang};
15use crate::tui::model::{ClusterView, Fail, Model, Pane};
16use crate::tui::screens::cluster::{
17    self, line_points, selected_index, selected_row, status_of, window_ms, Row, Unit, MON_CHARTS,
18};
19use crate::tui::view::widgets::{self, partial_seal, scrolling, short};
20
21pub fn draw(f: &mut Frame, area: Rect, m: &Model) {
22    match m.cluster.view {
23        ClusterView::List => draw_list(f, area, m),
24        ClusterView::Detail => draw_detail(f, area, m),
25        ClusterView::Monitor => draw_monitor(f, area, m),
26    }
27}
28
29/// The text for a read that failed and has nothing to show.
30pub fn error_text(m: &Model, fail: &Fail) -> String {
31    let tx = m.tx();
32    if fail.is_offline() {
33        tx.tw(
34            "error.offline",
35            &[("url", &m.url), ("message", &fail.message)],
36        )
37    } else if fail.is_denied() {
38        widgets::denied_text(m, fail)
39    } else if fail.code == "cluster.node-draining" {
40        tx.t("error.draining")
41    } else {
42        tx.tw(
43            "error.api",
44            &[("code", &fail.code), ("message", &fail.message)],
45        )
46    }
47}
48
49fn draw_message(f: &mut Frame, area: Rect, text: String, style: Style) {
50    f.render_widget(
51        Paragraph::new(text).style(style).wrap(Wrap { trim: false }),
52        area,
53    );
54}
55
56fn draw_list(f: &mut Frame, area: Rect, m: &Model) {
57    let tx = m.tx();
58    let th = m.theme;
59    if m.me.data.is_none() {
60        match &m.me.error {
61            Some(fail) => draw_message(f, area, error_text(m, fail), th.critical()),
62            None => draw_message(f, area, tx.t("state.loading"), th.dim()),
63        }
64        return;
65    }
66    let rows = cluster::rows(m);
67    if rows.is_empty() {
68        let error = m.status.error.as_ref().or(m.cluster.nodes.error.as_ref());
69        match error {
70            Some(fail) => draw_message(f, area, error_text(m, fail), th.critical()),
71            None => draw_message(f, area, tx.t("state.loading"), th.dim()),
72        }
73        return;
74    }
75    if !m.is_admin() || m.status.data.is_none() {
76        draw_limited(f, area, m, &rows);
77        return;
78    }
79    let conditions = m.status.data.as_ref().map_or(0, |s| s.conditions.len());
80    let partial = m.status.data.as_ref().is_some_and(|s| s.partial);
81    let cond_height = u16::try_from(conditions.clamp(1, 3)).unwrap_or(3) + 2;
82    let stopped = m.status.data.as_ref().is_some_and(|s| s.stopped);
83    let summary_height = 1 + u16::from(partial) + u16::from(stopped);
84    let [cond_area, summary_area, table_area, chart_area] = Layout::vertical([
85        Constraint::Length(cond_height),
86        Constraint::Length(summary_height),
87        Constraint::Min(4),
88        Constraint::Length(STRIP_HEIGHT),
89    ])
90    .areas(area);
91    draw_conditions(f, cond_area, m);
92    draw_summary(f, summary_area, m);
93    draw_nodes(f, table_area, m, &rows);
94    draw_strip(f, chart_area, m);
95}
96
97/// A token that is not the root token: only the list of nodes, and why the rest is missing (TUI-CLU-07).
98fn draw_limited(f: &mut Frame, area: Rect, m: &Model, rows: &[Row]) {
99    let th = m.theme;
100    let tx = m.tx();
101    let reason = match &m.status_denied {
102        Some(fail) => widgets::denied_text(m, fail),
103        None => tx.t("denied.root"),
104    };
105    let [note, table] = Layout::vertical([Constraint::Length(3), Constraint::Min(3)]).areas(area);
106    f.render_widget(
107        Paragraph::new(format!("{} {reason}", th.icon(Severity::Warning)))
108            .style(th.warning())
109            .wrap(Wrap { trim: false }),
110        note,
111    );
112    let header = TableRow::new([
113        tx.t("col.node"),
114        tx.t("col.state"),
115        tx.t("col.drain"),
116        tx.t("col.version"),
117    ])
118    .style(th.title());
119    let selected = selected_index(m, rows);
120    let body: Vec<TableRow> = rows
121        .iter()
122        .enumerate()
123        .map(|(i, r)| {
124            let version = m
125                .cluster
126                .nodes
127                .data
128                .as_ref()
129                .and_then(|n| n.items.iter().find(|x| x.node_id == r.id))
130                .map(|x| x.version.clone())
131                .unwrap_or_default();
132            let marker = if selected == Some(i) {
133                th.selector()
134            } else {
135                "  "
136            };
137            let id = if r.is_self {
138                format!("{marker}{} *", short(&r.id))
139            } else {
140                format!("{marker}{}", short(&r.id))
141            };
142            let row = TableRow::new([id, state_word(m, r), r.drain_state.clone(), version]);
143            if selected == Some(i) {
144                row.style(th.selected())
145            } else {
146                row
147            }
148        })
149        .collect();
150    let widths = [
151        Constraint::Length(14),
152        Constraint::Length(14),
153        Constraint::Length(10),
154        Constraint::Fill(1),
155    ];
156    f.render_widget(
157        Table::new(body, widths).header(header).block(
158            Block::bordered().title(format!(" {} ", tx.tn("cluster.nodes", rows.len() as u64))),
159        ),
160        table,
161    );
162}
163
164fn state_word(m: &Model, row: &Row) -> String {
165    let th = m.theme;
166    if !row.reachable {
167        return format!("{} {}", th.offline(), m.tx().t("state.unreachable"));
168    }
169    if row.state.is_empty() {
170        return "—".to_owned();
171    }
172    row.state.clone()
173}
174
175fn draw_conditions(f: &mut Frame, area: Rect, m: &Model) {
176    let tx = m.tx();
177    let th = m.theme;
178    let focused = m.cluster.pane == Pane::Conditions;
179    let title = format!(" {} ", tx.t("cluster.conditions"));
180    let border = if focused {
181        th.accent()
182    } else {
183        Style::default()
184    };
185    let block = Block::bordered().title(title).border_style(border);
186    let Some(status) = &m.status.data else {
187        f.render_widget(block, area);
188        return;
189    };
190    let lines: Vec<Line> = if status.conditions.is_empty() {
191        vec![Line::from(Span::styled(
192            format!("{} {}", th.icon(Severity::Ok), tx.t("health.ok")),
193            th.ok(),
194        ))]
195    } else {
196        status
197            .conditions
198            .iter()
199            .enumerate()
200            .map(|(i, c)| {
201                let name = tx.t(&format!("condition.{}", c.name));
202                let marker = if focused && i == m.cluster.condition {
203                    th.selector()
204                } else {
205                    "  "
206                };
207                let style = if focused && i == m.cluster.condition {
208                    th.severity(c.severity).patch(th.selected())
209                } else {
210                    th.severity(c.severity)
211                };
212                Line::from(Span::styled(
213                    format!("{marker}{} {name}", th.icon(c.severity)),
214                    style,
215                ))
216            })
217            .collect()
218    };
219    f.render_widget(Paragraph::new(lines).block(block), area);
220}
221
222fn draw_summary(f: &mut Frame, area: Rect, m: &Model) {
223    let tx = m.tx();
224    let th = m.theme;
225    let Some(status) = &m.status.data else { return };
226    let members: Vec<String> = status
227        .members
228        .iter()
229        .map(|(state, n)| format!("{state} {n}"))
230        .collect();
231    let quorum = if status.quorum {
232        format!("{} {}", th.icon(Severity::Ok), tx.t("summary.quorum"))
233    } else {
234        format!(
235            "{} {}",
236            th.icon(Severity::Critical),
237            tx.t("summary.no-quorum")
238        )
239    };
240    let quorum_style = if status.quorum {
241        th.ok()
242    } else {
243        th.critical()
244    };
245    let mut spans = vec![
246        Span::raw(format!(
247            "{}: {} · ",
248            tx.t("summary.members"),
249            members.join(", ")
250        )),
251        Span::styled(quorum, quorum_style),
252        Span::raw(format!(
253            " · {} {} · {} {}",
254            tx.t("summary.table"),
255            status.members_version,
256            tx.t("summary.syncing"),
257            i18n::group(m.lang, status.syncing_blocks)
258        )),
259    ];
260    if m.status.is_stale() {
261        spans.push(Span::styled(
262            format!(" · {}", tx.t("state.stale")),
263            th.warning(),
264        ));
265    }
266    let mut lines = vec![Line::from(spans)];
267    if status.stopped {
268        let key = if status.planned_stop {
269            "summary.stopped-planned"
270        } else {
271            "summary.stopped"
272        };
273        let text = tx.tw(
274            key,
275            &[("at", &status.stopped_at.clone().unwrap_or_default())],
276        );
277        lines.push(Line::from(Span::styled(
278            format!("{} {text}", th.icon(Severity::Critical)),
279            th.critical(),
280        )));
281    }
282    if status.partial {
283        lines.push(partial_seal(m, th, &status.missing));
284    }
285    f.render_widget(Paragraph::new(lines), area);
286}
287
288fn draw_nodes(f: &mut Frame, area: Rect, m: &Model, rows: &[Row]) {
289    let tx = m.tx();
290    let th = m.theme;
291    let lang = m.lang;
292    let full = area.width >= 100;
293    let focused = m.cluster.pane == Pane::Nodes;
294    let mut headers = vec![
295        tx.t("col.node"),
296        tx.t("col.state"),
297        tx.t("col.drain"),
298        tx.t("col.memory"),
299        tx.t("col.messages"),
300    ];
301    let mut widths = vec![
302        Constraint::Length(14),
303        Constraint::Length(10),
304        Constraint::Length(9),
305        Constraint::Length(14),
306        Constraint::Length(15),
307    ];
308    if full {
309        headers.push(tx.t("col.disk-free"));
310        widths.push(Constraint::Length(10));
311    }
312    headers.extend([tx.t("col.sync"), tx.t("col.requests")]);
313    widths.extend([Constraint::Length(5), Constraint::Length(5)]);
314    if full {
315        headers.push(tx.t("col.rejected"));
316        widths.push(Constraint::Length(6));
317    }
318    let selected = selected_index(m, rows);
319    let body: Vec<TableRow> = rows
320        .iter()
321        .enumerate()
322        .map(|(i, r)| {
323            let marker = if selected == Some(i) {
324                th.selector()
325            } else {
326                "  "
327            };
328            let mut cells = vec![Cell::from(format!("{marker}{}", short(&r.id)))];
329            match status_of(m, &r.id) {
330                Some(s) => {
331                    let ratio = s.memory_ratio();
332                    let mem_style = if ratio >= 0.95 {
333                        th.critical()
334                    } else if ratio >= 0.8 {
335                        th.warning()
336                    } else {
337                        Style::default()
338                    };
339                    cells.push(Cell::from(r.state.clone()));
340                    cells.push(Cell::from(r.drain_state.clone()));
341                    let memory = if s.memory_limit == 0 {
342                        i18n::bytes(lang, s.memory_used)
343                    } else {
344                        format!("{} {:>3}%", th.bar(ratio, 8), (ratio * 100.0).round())
345                    };
346                    cells.push(Cell::from(memory).style(mem_style));
347                    // Memory and disk apart: a flush moves one to the other and the sum would not show it.
348                    cells.push(Cell::from(format!(
349                        "{}/{}",
350                        i18n::group(lang, s.memory_messages),
351                        i18n::group(lang, s.disk_messages)
352                    )));
353                    if full {
354                        cells.push(Cell::from(i18n::bytes(lang, s.disk_free)));
355                    }
356                    cells.push(Cell::from(i18n::group(lang, s.syncing_blocks)));
357                    cells.push(Cell::from(format!("{}", s.active_requests.round())));
358                    if full {
359                        cells.push(Cell::from(format!("{}", s.rate_limited.round())));
360                    }
361                }
362                None if r.reachable => {
363                    // Known only by `GET /nodes` (for example drained, out of the member table).
364                    cells.push(Cell::from(r.state.clone()));
365                    cells.push(Cell::from(r.drain_state.clone()));
366                    let blanks = widths.len() - cells.len();
367                    cells.extend((0..blanks).map(|_| Cell::from("—")));
368                }
369                None => {
370                    cells.push(Cell::from(state_word(m, r)).style(th.critical()));
371                    let blanks = widths.len() - cells.len();
372                    cells.extend((0..blanks).map(|_| Cell::from("—")));
373                }
374            }
375            let row = TableRow::new(cells);
376            if selected == Some(i) {
377                row.style(th.selected())
378            } else if !r.reachable {
379                row.style(th.critical())
380            } else {
381                row
382            }
383        })
384        .collect();
385    let border = if focused {
386        th.accent()
387    } else {
388        Style::default()
389    };
390    let table = Table::new(body, widths)
391        .header(TableRow::new(headers).style(th.title()))
392        .block(
393            Block::bordered()
394                .title(format!(" {} ", tx.tn("cluster.nodes", rows.len() as u64)))
395                .border_style(border),
396        );
397    f.render_widget(table, area);
398}
399
400/// How tall the strip of charts under the table is.
401const STRIP_HEIGHT: u16 = 10;
402
403/// The charts of the selected node under the table (TUI-CLU-04): memory, messages in memory and open requests,
404/// side by side, over the same window as the charts view.
405fn draw_strip(f: &mut Frame, area: Rect, m: &Model) {
406    if area.height == 0 {
407        return;
408    }
409    let Some(row) = selected_row(m) else { return };
410    let nodes = [row.id];
411    let cells = Layout::horizontal([Constraint::Ratio(1, 3); 3]).split(area);
412    for (index, cell) in STRIP_CHARTS.iter().zip(cells.iter()) {
413        draw_chart(f, *cell, m, &MON_CHARTS[*index], &nodes, true);
414    }
415}
416
417/// The indicators the strip shows, as positions in `MON_CHARTS`.
418const STRIP_CHARTS: [usize; 3] = [0, 1, 3];
419
420// ---------- the detail of a node ----------
421
422fn pair(m: &Model, label: &str, value: impl Into<String>) -> Line<'static> {
423    Line::from(vec![
424        Span::styled(format!("{label}: "), m.theme.dim()),
425        Span::raw(value.into()),
426    ])
427}
428
429fn heading(m: &Model, text: String) -> Line<'static> {
430    Line::from(Span::styled(
431        text,
432        m.theme.title().add_modifier(Modifier::UNDERLINED),
433    ))
434}
435
436/// The lines of the detail screen: the node, then its metrics. The same lines size the scroll (TUI-FRM-04).
437pub fn detail_lines(m: &Model) -> Vec<Line<'static>> {
438    let tx = m.tx();
439    let th = m.theme;
440    let lang = m.lang;
441    let Some(row) = selected_row(m) else {
442        return vec![Line::from(tx.t("detail.no-node"))];
443    };
444    let mut lines = vec![Line::from(Span::styled(
445        format!("{} {}", tx.t("detail.node"), row.id),
446        th.title(),
447    ))];
448    let Some(s) = status_of(m, &row.id) else {
449        lines.push(Line::from(Span::styled(
450            format!("{} {}", th.offline(), tx.t("state.unreachable")),
451            th.critical(),
452        )));
453        return lines;
454    };
455    lines.push(pair(
456        m,
457        &tx.t("col.state"),
458        format!("{} · {}: {}", s.state, tx.t("col.drain"), s.drain_state),
459    ));
460    lines.push(pair(
461        m,
462        &tx.t("col.version"),
463        format!("{} · {}", s.version, s.mode),
464    ));
465    if let Some(started) = s.started_at.as_deref().and_then(i18n::parse_instant) {
466        let up = u64::try_from((m.wall_ms - started) / 1000).unwrap_or(0);
467        lines.push(pair(
468            m,
469            &tx.t("detail.started"),
470            format!(
471                "{} ({})",
472                i18n::date_time(lang, started),
473                i18n::duration(up)
474            ),
475        ));
476    }
477    lines.push(Line::default());
478    lines.push(heading(m, tx.t("detail.storage")));
479    let ratio = s.memory_ratio();
480    let mem_style = if ratio >= 0.95 {
481        th.critical()
482    } else if ratio >= 0.8 {
483        th.warning()
484    } else {
485        th.ok()
486    };
487    lines.push(Line::from(vec![
488        Span::styled(format!("{}: ", tx.t("col.memory")), th.dim()),
489        Span::styled(
490            if s.memory_limit == 0 {
491                format!(
492                    "{} ({})",
493                    i18n::bytes(lang, s.memory_used),
494                    tx.t("detail.no-limit")
495                )
496            } else {
497                format!(
498                    "{} {:>3}%  {} / {}",
499                    th.bar(ratio, 20),
500                    (ratio * 100.0).round(),
501                    i18n::bytes(lang, s.memory_used),
502                    i18n::bytes(lang, s.memory_limit)
503                )
504            },
505            mem_style,
506        ),
507    ]));
508    lines.push(pair(
509        m,
510        &tx.t("detail.messages"),
511        tx.tw(
512            "detail.messages-value",
513            &[
514                ("memory", &i18n::group(lang, s.memory_messages)),
515                ("disk", &i18n::group(lang, s.disk_messages)),
516            ],
517        ),
518    ));
519    lines.push(pair(
520        m,
521        &tx.t("col.disk-free"),
522        i18n::bytes(lang, s.disk_free),
523    ));
524    lines.push(Line::default());
525    lines.push(heading(m, tx.t("detail.signals")));
526    lines.extend(signal_lines(m, s));
527    lines.push(Line::default());
528    lines.push(heading(m, tx.t("detail.backup")));
529    lines.extend(backup_lines(m));
530    if let Some(watch) = m.cluster.watch.as_ref().filter(|w| w.node == row.id) {
531        lines.push(Line::default());
532        lines.push(heading(m, tx.t("detail.drain")));
533        let text = match &m.cluster.drain.data {
534            Some(d) => tx.tw(
535                "detail.drain-progress",
536                &[
537                    ("state", &d.state),
538                    ("pending", &i18n::group(lang, d.pending)),
539                ],
540            ),
541            None => tx.t("state.loading"),
542        };
543        let _ = watch;
544        lines.push(Line::from(text));
545    }
546    lines.push(Line::default());
547    lines.push(heading(m, tx.t("detail.metrics")));
548    match &m.cluster.metrics.data {
549        Some(metrics) => {
550            for sample in metrics.samples.iter().take(500) {
551                lines.push(Line::from(vec![
552                    Span::raw(sample.series()),
553                    Span::styled(format!("  {}", sample.value), th.accent()),
554                ]));
555            }
556        }
557        None => lines.push(Line::from(Span::styled(
558            match &m.cluster.metrics.error {
559                Some(fail) => error_text(m, fail),
560                None => tx.t("state.loading"),
561            },
562            th.dim(),
563        ))),
564    }
565    lines
566}
567
568fn signal_lines(m: &Model, s: &NodeStatus) -> Vec<Line<'static>> {
569    let tx = m.tx();
570    let th = m.theme;
571    let lang = m.lang;
572    let flag = |bad: bool| if bad { th.warning() } else { Style::default() };
573    let quorum = if s.quorum {
574        Span::styled(
575            format!("{} {}", th.icon(Severity::Ok), tx.t("summary.quorum")),
576            th.ok(),
577        )
578    } else {
579        Span::styled(
580            format!(
581                "{} {}",
582                th.icon(Severity::Critical),
583                tx.t("summary.no-quorum")
584            ),
585            th.critical(),
586        )
587    };
588    let count = |label: &str, n: u64| {
589        Line::from(vec![
590            Span::styled(format!("{label}: "), th.dim()),
591            Span::styled(i18n::group(lang, n), flag(n > 0)),
592        ])
593    };
594    let rtt = match s.rtt_p95 {
595        Some(v) => fmt_unit(lang, Unit::Seconds, v),
596        None => "—".to_owned(),
597    };
598    vec![
599        Line::from(vec![
600            Span::styled(format!("{}: ", tx.t("detail.quorum")), th.dim()),
601            quorum,
602        ]),
603        count(&tx.t("detail.channels"), s.channels),
604        count(&tx.t("detail.syncing"), s.syncing_blocks),
605        count(&tx.t("detail.below-replication"), s.below_replication),
606        count(&tx.t("detail.below-quorum"), s.below_quorum),
607        count(&tx.t("detail.frozen"), s.frozen_nodes),
608        pair(
609            m,
610            &tx.t("detail.requests"),
611            format!("{}", s.active_requests.round()),
612        ),
613        pair(
614            m,
615            &tx.t("detail.rejected"),
616            format!("{}", s.rate_limited.round()),
617        ),
618        pair(m, &tx.t("detail.rtt"), rtt),
619    ]
620}
621
622fn backup_lines(m: &Model) -> Vec<Line<'static>> {
623    let tx = m.tx();
624    let th = m.theme;
625    let lang = m.lang;
626    match &m.cluster.backup.data {
627        Some(Some(b)) => {
628            let yes_no = |v: Option<bool>| match v {
629                Some(true) => tx.t("value.yes"),
630                Some(false) => tx.t("value.no"),
631                None => "—".to_owned(),
632            };
633            let last = b
634                .last_success
635                .as_deref()
636                .and_then(i18n::parse_instant)
637                .map_or_else(|| "—".to_owned(), |ms| i18n::date_time(lang, ms));
638            vec![
639                pair(m, &tx.t("detail.s3-reachable"), yes_no(b.reachable)),
640                pair(
641                    m,
642                    &tx.t("detail.pending"),
643                    b.pending.map_or("—".to_owned(), |n| i18n::group(lang, n)),
644                ),
645                pair(m, &tx.t("detail.last-success"), last),
646                pair(
647                    m,
648                    &tx.t("detail.segments"),
649                    b.segments.map_or("—".to_owned(), |n| i18n::group(lang, n)),
650                ),
651            ]
652        }
653        Some(None) => vec![Line::from(Span::styled(
654            tx.t("detail.backup-off"),
655            th.dim(),
656        ))],
657        None => vec![Line::from(Span::styled(
658            match &m.cluster.backup.error {
659                Some(fail) => error_text(m, fail),
660                None => tx.t("state.loading"),
661            },
662            th.dim(),
663        ))],
664    }
665}
666
667fn draw_detail(f: &mut Frame, area: Rect, m: &Model) {
668    scrolling(f, area, detail_lines(m), m.cluster.scroll);
669}
670
671// ---------- the charts of a node ----------
672
673pub fn fmt_unit(lang: Lang, unit: Unit, value: f64) -> String {
674    match unit {
675        Unit::Bytes => i18n::bytes(lang, value.max(0.0) as u64),
676        Unit::Count => i18n::group(lang, value.round().max(0.0) as u64),
677        Unit::PerSecond => format!("{}/s", i18n::decimal(lang, value, 2)),
678        Unit::Seconds if value < 1.0 => format!("{} ms", i18n::decimal(lang, value * 1000.0, 1)),
679        Unit::Seconds => format!("{} s", i18n::decimal(lang, value, 2)),
680    }
681}
682
683/// The nodes that have something to chart, in the order of the table.
684fn charted_nodes(m: &Model) -> Vec<String> {
685    cluster::rows(m)
686        .into_iter()
687        .filter(|row| {
688            MON_CHARTS.iter().any(|def| {
689                def.lines
690                    .first()
691                    .is_some_and(|(s, _)| m.history.series(&row.id, *s).is_some())
692            })
693        })
694        .map(|row| row.id)
695        .collect()
696}
697
698/// The charts of the whole cluster: a list of the indicators on the left, with the one under the cursor drawn
699/// next to it (a line per node), and `Enter` to give it the whole screen.
700fn draw_monitor(f: &mut Frame, area: Rect, m: &Model) {
701    let tx = m.tx();
702    let th = m.theme;
703    let nodes = charted_nodes(m);
704    let chart = m.cluster.monitor.chart.min(MON_CHARTS.len() - 1);
705    let def = &MON_CHARTS[chart];
706    let [main, legend_area, note_area] = Layout::vertical([
707        Constraint::Min(5),
708        Constraint::Length(1),
709        Constraint::Length(2),
710    ])
711    .areas(area);
712    if m.cluster.monitor.full {
713        draw_chart(f, main, m, def, &nodes, false);
714    } else {
715        let width = if main.width >= 100 { 40 } else { 28 };
716        let [list_area, chart_area] =
717            Layout::horizontal([Constraint::Length(width), Constraint::Min(20)]).areas(main);
718        draw_chart_list(f, list_area, m, &nodes, chart);
719        draw_chart(f, chart_area, m, def, &nodes, false);
720    }
721
722    // The legend: one marker and one color per node (TUI-MON-02).
723    let legend: Vec<Span> = nodes
724        .iter()
725        .enumerate()
726        .map(|(i, id)| Span::styled(format!(" {} {} ", th.marker(i), short(id)), th.series(i)))
727        .collect();
728    f.render_widget(Paragraph::new(Line::from(legend)), legend_area);
729
730    // What the chart honestly shows (TUI-MON-05) and its state.
731    let at = m.cluster.monitor.frozen.unwrap_or(m.now);
732    let window = window_ms(m);
733    let collected = def
734        .lines
735        .first()
736        .into_iter()
737        .flat_map(|(series, _)| {
738            nodes
739                .iter()
740                .filter_map(|id| m.history.series(id, *series).map(|r| r.collected_ms(at)))
741        })
742        .max()
743        .unwrap_or(0);
744    let mut note = if collected < window {
745        tx.tw(
746            "chart.collected",
747            &[("time", &i18n::duration(collected / 1000))],
748        )
749    } else {
750        tx.t("chart.history-note")
751    };
752    if m.cluster.monitor.frozen.is_some() {
753        note.push_str(&format!(" · {}", tx.t("chart.paused")));
754    }
755    note.push_str(&format!(
756        " · {}",
757        tx.t(if m.cluster.monitor.fixed_scale {
758            "chart.scale-fixed"
759        } else {
760            "chart.scale-auto"
761        })
762    ));
763    let partial = def.lines.iter().take(1).any(|(series, _)| {
764        nodes
765            .iter()
766            .any(|id| line_points(m, id, *series).iter().any(|p| p.partial))
767    });
768    if partial {
769        note.push_str(&format!(" · {}", tx.t("chart.partial-samples")));
770    }
771    f.render_widget(
772        Paragraph::new(note)
773            .style(th.dim())
774            .wrap(Wrap { trim: false }),
775        note_area,
776    );
777}
778
779/// The figure of an indicator for the whole cluster: the sum of the nodes' last values, or the worst for a round
780/// trip; `None` when nothing was read.
781fn cluster_figure(m: &Model, def: &cluster::ChartDef, nodes: &[String]) -> Option<f64> {
782    let (series, _) = def.lines.first().copied()?;
783    let lasts: Vec<f64> = nodes
784        .iter()
785        .filter_map(|id| line_points(m, id, series).last().map(|p| p.value))
786        .collect();
787    if lasts.is_empty() {
788        return None;
789    }
790    Some(if def.unit == Unit::Seconds {
791        lasts.iter().copied().fold(0.0, f64::max)
792    } else {
793        lasts.iter().sum()
794    })
795}
796
797/// The list of indicators: the name and the current figure of each, the one under the cursor marked.
798fn draw_chart_list(f: &mut Frame, area: Rect, m: &Model, nodes: &[String], selected: usize) {
799    let tx = m.tx();
800    let th = m.theme;
801    let inner_width = usize::from(area.width.saturating_sub(2));
802    let lines: Vec<Line> = MON_CHARTS
803        .iter()
804        .enumerate()
805        .map(|(i, def)| {
806            let marker = if i == selected { th.selector() } else { "  " };
807            let value = cluster_figure(m, def, nodes)
808                .map_or_else(|| "—".to_owned(), |v| fmt_unit(m.lang, def.unit, v));
809            // A long name gives way to the figure.
810            let room = inner_width.saturating_sub(value.chars().count() + 1);
811            let name: String = format!("{marker}{}", tx.t(def.title))
812                .chars()
813                .take(room)
814                .collect();
815            let pad = inner_width
816                .saturating_sub(name.chars().count() + value.chars().count())
817                .max(1);
818            let style = if i == selected {
819                th.selected()
820            } else {
821                ratatui::style::Style::default()
822            };
823            Line::from(vec![
824                Span::styled(format!("{name}{}", " ".repeat(pad)), style),
825                Span::styled(value, if i == selected { style } else { th.dim() }),
826            ])
827        })
828        .collect();
829    f.render_widget(
830        Paragraph::new(lines).block(
831            Block::bordered()
832                .title(format!(" {} ", tx.t("chart.list")))
833                .border_style(th.accent()),
834        ),
835        area,
836    );
837}
838
839/// One chart of the grid: the indicator's main series, a line per node.
840fn draw_chart(
841    f: &mut Frame,
842    area: Rect,
843    m: &Model,
844    def: &cluster::ChartDef,
845    nodes: &[String],
846    compact: bool,
847) {
848    let tx = m.tx();
849    let th = m.theme;
850    let lang = m.lang;
851    let Some((series, _)) = def.lines.first().copied() else {
852        return;
853    };
854    let at = m.cluster.monitor.frozen.unwrap_or(m.now);
855    let window_s = window_ms(m) as f64 / 1000.0;
856    let lines: Vec<Vec<history::Point>> =
857        nodes.iter().map(|id| line_points(m, id, series)).collect();
858    let xy: Vec<Vec<(f64, f64)>> = lines
859        .iter()
860        .map(|points| {
861            points
862                .iter()
863                .map(|p| ((p.at as f64 - at as f64) / 1000.0, p.value))
864                .collect()
865        })
866        .collect();
867    let values: Vec<f64> = lines.iter().flatten().map(|p| p.value).collect();
868    if values.is_empty() {
869        f.render_widget(
870            Block::bordered().title(format!(" {} ", tx.t(def.title))),
871            area,
872        );
873        let inner = area.inner(ratatui::layout::Margin::new(2, 1));
874        draw_message(f, inner, tx.t("chart.no-data"), th.dim());
875        return;
876    }
877
878    // The cluster figure of the chart: the sum, or the worst node for a round trip.
879    let lasts: Vec<f64> = lines
880        .iter()
881        .filter_map(|l| l.last().map(|p| p.value))
882        .collect();
883    let figure = if def.unit == Unit::Seconds {
884        let worst = lasts.iter().copied().fold(0.0, f64::max);
885        if compact {
886            fmt_unit(lang, def.unit, worst)
887        } else {
888            tx.tw(
889                "chart.worst",
890                &[("value", &fmt_unit(lang, def.unit, worst))],
891            )
892        }
893    } else {
894        let sum: f64 = lasts.iter().sum();
895        if compact {
896            fmt_unit(lang, def.unit, sum)
897        } else {
898            tx.tw("chart.total", &[("value", &fmt_unit(lang, def.unit, sum))])
899        }
900    };
901
902    let (low, high) = scale(m, nodes, def, &values);
903    let mid = (low + high) / 2.0;
904    let datasets: Vec<Dataset> = xy
905        .iter()
906        .enumerate()
907        .filter(|(_, points)| !points.is_empty())
908        .map(|(i, points)| {
909            Dataset::default()
910                .marker(if th.ascii {
911                    Marker::Dot
912                } else {
913                    Marker::Braille
914                })
915                .graph_type(GraphType::Line)
916                .style(th.series(i))
917                .data(points)
918        })
919        .collect();
920    let chart = Chart::new(datasets)
921        .legend_position(None)
922        .block(Block::bordered().title(format!(" {} · {} ", tx.t(def.title), figure)))
923        .x_axis(
924            Axis::default()
925                .bounds([-window_s, 0.0])
926                .labels(vec![
927                    format!("-{}", i18n::duration(window_s as u64)),
928                    tx.t("chart.now"),
929                ])
930                .style(th.dim()),
931        )
932        .y_axis(
933            Axis::default()
934                .bounds([low, high])
935                .labels(vec![
936                    fmt_unit(lang, def.unit, low),
937                    fmt_unit(lang, def.unit, mid),
938                    fmt_unit(lang, def.unit, high),
939                ])
940                .style(th.dim()),
941        );
942    f.render_widget(chart, area);
943}
944
945/// The vertical bounds: the window's own range with some air, or from zero to the largest value ever kept.
946fn scale(m: &Model, nodes: &[String], def: &cluster::ChartDef, values: &[f64]) -> (f64, f64) {
947    if m.cluster.monitor.fixed_scale {
948        let high = def
949            .lines
950            .iter()
951            .take(1)
952            .flat_map(|(series, _)| nodes.iter().map(move |id| (id, *series)))
953            .flat_map(|(id, series)| {
954                m.history
955                    .series(id, series)
956                    .map(|r| r.window(m.now, m.history.retention_ms()))
957                    .unwrap_or_default()
958            })
959            .map(|p| p.value)
960            .fold(f64::NEG_INFINITY, f64::max);
961        return (
962            0.0,
963            if high.is_finite() && high > 0.0 {
964                high
965            } else {
966                1.0
967            },
968        );
969    }
970    let low = values.iter().copied().fold(f64::INFINITY, f64::min);
971    let high = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
972    let pad = if (high - low).abs() < f64::EPSILON {
973        high.abs().max(1.0) * 0.1
974    } else {
975        (high - low) * 0.1
976    };
977    let floor = if low >= 0.0 {
978        (low - pad).max(0.0)
979    } else {
980        low - pad
981    };
982    (floor, high + pad)
983}