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(m, &tx.t("col.version"), s.version.clone()));
461    if let Some(started) = s.started_at.as_deref().and_then(i18n::parse_instant) {
462        let up = u64::try_from((m.wall_ms - started) / 1000).unwrap_or(0);
463        lines.push(pair(
464            m,
465            &tx.t("detail.started"),
466            format!(
467                "{} ({})",
468                i18n::date_time(lang, started),
469                i18n::duration(up)
470            ),
471        ));
472    }
473    lines.push(Line::default());
474    lines.push(heading(m, tx.t("detail.storage")));
475    let ratio = s.memory_ratio();
476    let mem_style = if ratio >= 0.95 {
477        th.critical()
478    } else if ratio >= 0.8 {
479        th.warning()
480    } else {
481        th.ok()
482    };
483    lines.push(Line::from(vec![
484        Span::styled(format!("{}: ", tx.t("col.memory")), th.dim()),
485        Span::styled(
486            if s.memory_limit == 0 {
487                format!(
488                    "{} ({})",
489                    i18n::bytes(lang, s.memory_used),
490                    tx.t("detail.no-limit")
491                )
492            } else {
493                format!(
494                    "{} {:>3}%  {} / {}",
495                    th.bar(ratio, 20),
496                    (ratio * 100.0).round(),
497                    i18n::bytes(lang, s.memory_used),
498                    i18n::bytes(lang, s.memory_limit)
499                )
500            },
501            mem_style,
502        ),
503    ]));
504    lines.push(pair(
505        m,
506        &tx.t("detail.messages"),
507        tx.tw(
508            "detail.messages-value",
509            &[
510                ("memory", &i18n::group(lang, s.memory_messages)),
511                ("disk", &i18n::group(lang, s.disk_messages)),
512            ],
513        ),
514    ));
515    lines.push(pair(
516        m,
517        &tx.t("col.disk-free"),
518        i18n::bytes(lang, s.disk_free),
519    ));
520    lines.push(Line::default());
521    lines.push(heading(m, tx.t("detail.signals")));
522    lines.extend(signal_lines(m, s));
523    lines.push(Line::default());
524    lines.push(heading(m, tx.t("detail.backup")));
525    lines.extend(backup_lines(m));
526    if let Some(watch) = m.cluster.watch.as_ref().filter(|w| w.node == row.id) {
527        lines.push(Line::default());
528        lines.push(heading(m, tx.t("detail.drain")));
529        let text = match &m.cluster.drain.data {
530            Some(d) => tx.tw(
531                "detail.drain-progress",
532                &[
533                    ("state", &d.state),
534                    ("pending", &i18n::group(lang, d.pending)),
535                ],
536            ),
537            None => tx.t("state.loading"),
538        };
539        let _ = watch;
540        lines.push(Line::from(text));
541    }
542    lines.push(Line::default());
543    lines.push(heading(m, tx.t("detail.metrics")));
544    match &m.cluster.metrics.data {
545        Some(metrics) => {
546            for sample in metrics.samples.iter().take(500) {
547                lines.push(Line::from(vec![
548                    Span::raw(sample.series()),
549                    Span::styled(format!("  {}", sample.value), th.accent()),
550                ]));
551            }
552        }
553        None => lines.push(Line::from(Span::styled(
554            match &m.cluster.metrics.error {
555                Some(fail) => error_text(m, fail),
556                None => tx.t("state.loading"),
557            },
558            th.dim(),
559        ))),
560    }
561    lines
562}
563
564fn signal_lines(m: &Model, s: &NodeStatus) -> Vec<Line<'static>> {
565    let tx = m.tx();
566    let th = m.theme;
567    let lang = m.lang;
568    let flag = |bad: bool| if bad { th.warning() } else { Style::default() };
569    let quorum = if s.quorum {
570        Span::styled(
571            format!("{} {}", th.icon(Severity::Ok), tx.t("summary.quorum")),
572            th.ok(),
573        )
574    } else {
575        Span::styled(
576            format!(
577                "{} {}",
578                th.icon(Severity::Critical),
579                tx.t("summary.no-quorum")
580            ),
581            th.critical(),
582        )
583    };
584    let count = |label: &str, n: u64| {
585        Line::from(vec![
586            Span::styled(format!("{label}: "), th.dim()),
587            Span::styled(i18n::group(lang, n), flag(n > 0)),
588        ])
589    };
590    let rtt = match s.rtt_p95 {
591        Some(v) => fmt_unit(lang, Unit::Seconds, v),
592        None => "—".to_owned(),
593    };
594    vec![
595        Line::from(vec![
596            Span::styled(format!("{}: ", tx.t("detail.quorum")), th.dim()),
597            quorum,
598        ]),
599        count(&tx.t("detail.channels"), s.channels),
600        count(&tx.t("detail.syncing"), s.syncing_blocks),
601        count(&tx.t("detail.below-replication"), s.below_replication),
602        count(&tx.t("detail.below-quorum"), s.below_quorum),
603        count(&tx.t("detail.frozen"), s.frozen_nodes),
604        pair(
605            m,
606            &tx.t("detail.requests"),
607            format!("{}", s.active_requests.round()),
608        ),
609        pair(
610            m,
611            &tx.t("detail.rejected"),
612            format!("{}", s.rate_limited.round()),
613        ),
614        pair(m, &tx.t("detail.rtt"), rtt),
615    ]
616}
617
618fn backup_lines(m: &Model) -> Vec<Line<'static>> {
619    let tx = m.tx();
620    let th = m.theme;
621    let lang = m.lang;
622    match &m.cluster.backup.data {
623        Some(Some(b)) => {
624            let yes_no = |v: Option<bool>| match v {
625                Some(true) => tx.t("value.yes"),
626                Some(false) => tx.t("value.no"),
627                None => "—".to_owned(),
628            };
629            let last = b
630                .last_success
631                .as_deref()
632                .and_then(i18n::parse_instant)
633                .map_or_else(|| "—".to_owned(), |ms| i18n::date_time(lang, ms));
634            vec![
635                pair(m, &tx.t("detail.s3-reachable"), yes_no(b.reachable)),
636                pair(
637                    m,
638                    &tx.t("detail.pending"),
639                    b.pending.map_or("—".to_owned(), |n| i18n::group(lang, n)),
640                ),
641                pair(m, &tx.t("detail.last-success"), last),
642                pair(
643                    m,
644                    &tx.t("detail.segments"),
645                    b.segments.map_or("—".to_owned(), |n| i18n::group(lang, n)),
646                ),
647            ]
648        }
649        Some(None) => vec![Line::from(Span::styled(
650            tx.t("detail.backup-off"),
651            th.dim(),
652        ))],
653        None => vec![Line::from(Span::styled(
654            match &m.cluster.backup.error {
655                Some(fail) => error_text(m, fail),
656                None => tx.t("state.loading"),
657            },
658            th.dim(),
659        ))],
660    }
661}
662
663fn draw_detail(f: &mut Frame, area: Rect, m: &Model) {
664    scrolling(f, area, detail_lines(m), m.cluster.scroll);
665}
666
667// ---------- the charts of a node ----------
668
669pub fn fmt_unit(lang: Lang, unit: Unit, value: f64) -> String {
670    match unit {
671        Unit::Bytes => i18n::bytes(lang, value.max(0.0) as u64),
672        Unit::Count => i18n::group(lang, value.round().max(0.0) as u64),
673        Unit::PerSecond => format!("{}/s", i18n::decimal(lang, value, 2)),
674        Unit::Seconds if value < 1.0 => format!("{} ms", i18n::decimal(lang, value * 1000.0, 1)),
675        Unit::Seconds => format!("{} s", i18n::decimal(lang, value, 2)),
676    }
677}
678
679/// The nodes that have something to chart, in the order of the table.
680fn charted_nodes(m: &Model) -> Vec<String> {
681    cluster::rows(m)
682        .into_iter()
683        .filter(|row| {
684            MON_CHARTS.iter().any(|def| {
685                def.lines
686                    .first()
687                    .is_some_and(|(s, _)| m.history.series(&row.id, *s).is_some())
688            })
689        })
690        .map(|row| row.id)
691        .collect()
692}
693
694/// The charts of the whole cluster: a list of the indicators on the left, with the one under the cursor drawn
695/// next to it (a line per node), and `Enter` to give it the whole screen.
696fn draw_monitor(f: &mut Frame, area: Rect, m: &Model) {
697    let tx = m.tx();
698    let th = m.theme;
699    let nodes = charted_nodes(m);
700    let chart = m.cluster.monitor.chart.min(MON_CHARTS.len() - 1);
701    let def = &MON_CHARTS[chart];
702    let [main, legend_area, note_area] = Layout::vertical([
703        Constraint::Min(5),
704        Constraint::Length(1),
705        Constraint::Length(2),
706    ])
707    .areas(area);
708    if m.cluster.monitor.full {
709        draw_chart(f, main, m, def, &nodes, false);
710    } else {
711        let width = if main.width >= 100 { 40 } else { 28 };
712        let [list_area, chart_area] =
713            Layout::horizontal([Constraint::Length(width), Constraint::Min(20)]).areas(main);
714        draw_chart_list(f, list_area, m, &nodes, chart);
715        draw_chart(f, chart_area, m, def, &nodes, false);
716    }
717
718    // The legend: one marker and one color per node (TUI-MON-02).
719    let legend: Vec<Span> = nodes
720        .iter()
721        .enumerate()
722        .map(|(i, id)| Span::styled(format!(" {} {} ", th.marker(i), short(id)), th.series(i)))
723        .collect();
724    f.render_widget(Paragraph::new(Line::from(legend)), legend_area);
725
726    // What the chart honestly shows (TUI-MON-05) and its state.
727    let at = m.cluster.monitor.frozen.unwrap_or(m.now);
728    let window = window_ms(m);
729    let collected = def
730        .lines
731        .first()
732        .into_iter()
733        .flat_map(|(series, _)| {
734            nodes
735                .iter()
736                .filter_map(|id| m.history.series(id, *series).map(|r| r.collected_ms(at)))
737        })
738        .max()
739        .unwrap_or(0);
740    let mut note = if collected < window {
741        tx.tw(
742            "chart.collected",
743            &[("time", &i18n::duration(collected / 1000))],
744        )
745    } else {
746        tx.t("chart.history-note")
747    };
748    if m.cluster.monitor.frozen.is_some() {
749        note.push_str(&format!(" · {}", tx.t("chart.paused")));
750    }
751    note.push_str(&format!(
752        " · {}",
753        tx.t(if m.cluster.monitor.fixed_scale {
754            "chart.scale-fixed"
755        } else {
756            "chart.scale-auto"
757        })
758    ));
759    let partial = def.lines.iter().take(1).any(|(series, _)| {
760        nodes
761            .iter()
762            .any(|id| line_points(m, id, *series).iter().any(|p| p.partial))
763    });
764    if partial {
765        note.push_str(&format!(" · {}", tx.t("chart.partial-samples")));
766    }
767    f.render_widget(
768        Paragraph::new(note)
769            .style(th.dim())
770            .wrap(Wrap { trim: false }),
771        note_area,
772    );
773}
774
775/// The figure of an indicator for the whole cluster: the sum of the nodes' last values, or the worst for a round
776/// trip; `None` when nothing was read.
777fn cluster_figure(m: &Model, def: &cluster::ChartDef, nodes: &[String]) -> Option<f64> {
778    let (series, _) = def.lines.first().copied()?;
779    let lasts: Vec<f64> = nodes
780        .iter()
781        .filter_map(|id| line_points(m, id, series).last().map(|p| p.value))
782        .collect();
783    if lasts.is_empty() {
784        return None;
785    }
786    Some(if def.unit == Unit::Seconds {
787        lasts.iter().copied().fold(0.0, f64::max)
788    } else {
789        lasts.iter().sum()
790    })
791}
792
793/// The list of indicators: the name and the current figure of each, the one under the cursor marked.
794fn draw_chart_list(f: &mut Frame, area: Rect, m: &Model, nodes: &[String], selected: usize) {
795    let tx = m.tx();
796    let th = m.theme;
797    let inner_width = usize::from(area.width.saturating_sub(2));
798    let lines: Vec<Line> = MON_CHARTS
799        .iter()
800        .enumerate()
801        .map(|(i, def)| {
802            let marker = if i == selected { th.selector() } else { "  " };
803            let value = cluster_figure(m, def, nodes)
804                .map_or_else(|| "—".to_owned(), |v| fmt_unit(m.lang, def.unit, v));
805            // A long name gives way to the figure.
806            let room = inner_width.saturating_sub(value.chars().count() + 1);
807            let name: String = format!("{marker}{}", tx.t(def.title))
808                .chars()
809                .take(room)
810                .collect();
811            let pad = inner_width
812                .saturating_sub(name.chars().count() + value.chars().count())
813                .max(1);
814            let style = if i == selected {
815                th.selected()
816            } else {
817                ratatui::style::Style::default()
818            };
819            Line::from(vec![
820                Span::styled(format!("{name}{}", " ".repeat(pad)), style),
821                Span::styled(value, if i == selected { style } else { th.dim() }),
822            ])
823        })
824        .collect();
825    f.render_widget(
826        Paragraph::new(lines).block(
827            Block::bordered()
828                .title(format!(" {} ", tx.t("chart.list")))
829                .border_style(th.accent()),
830        ),
831        area,
832    );
833}
834
835/// One chart of the grid: the indicator's main series, a line per node.
836fn draw_chart(
837    f: &mut Frame,
838    area: Rect,
839    m: &Model,
840    def: &cluster::ChartDef,
841    nodes: &[String],
842    compact: bool,
843) {
844    let tx = m.tx();
845    let th = m.theme;
846    let lang = m.lang;
847    let Some((series, _)) = def.lines.first().copied() else {
848        return;
849    };
850    let at = m.cluster.monitor.frozen.unwrap_or(m.now);
851    let window_s = window_ms(m) as f64 / 1000.0;
852    let lines: Vec<Vec<history::Point>> =
853        nodes.iter().map(|id| line_points(m, id, series)).collect();
854    let xy: Vec<Vec<(f64, f64)>> = lines
855        .iter()
856        .map(|points| {
857            points
858                .iter()
859                .map(|p| ((p.at as f64 - at as f64) / 1000.0, p.value))
860                .collect()
861        })
862        .collect();
863    let values: Vec<f64> = lines.iter().flatten().map(|p| p.value).collect();
864    if values.is_empty() {
865        f.render_widget(
866            Block::bordered().title(format!(" {} ", tx.t(def.title))),
867            area,
868        );
869        let inner = area.inner(ratatui::layout::Margin::new(2, 1));
870        draw_message(f, inner, tx.t("chart.no-data"), th.dim());
871        return;
872    }
873
874    // The cluster figure of the chart: the sum, or the worst node for a round trip.
875    let lasts: Vec<f64> = lines
876        .iter()
877        .filter_map(|l| l.last().map(|p| p.value))
878        .collect();
879    let figure = if def.unit == Unit::Seconds {
880        let worst = lasts.iter().copied().fold(0.0, f64::max);
881        if compact {
882            fmt_unit(lang, def.unit, worst)
883        } else {
884            tx.tw(
885                "chart.worst",
886                &[("value", &fmt_unit(lang, def.unit, worst))],
887            )
888        }
889    } else {
890        let sum: f64 = lasts.iter().sum();
891        if compact {
892            fmt_unit(lang, def.unit, sum)
893        } else {
894            tx.tw("chart.total", &[("value", &fmt_unit(lang, def.unit, sum))])
895        }
896    };
897
898    let (low, high) = scale(m, nodes, def, &values);
899    let mid = (low + high) / 2.0;
900    let datasets: Vec<Dataset> = xy
901        .iter()
902        .enumerate()
903        .filter(|(_, points)| !points.is_empty())
904        .map(|(i, points)| {
905            Dataset::default()
906                .marker(if th.ascii {
907                    Marker::Dot
908                } else {
909                    Marker::Braille
910                })
911                .graph_type(GraphType::Line)
912                .style(th.series(i))
913                .data(points)
914        })
915        .collect();
916    let chart = Chart::new(datasets)
917        .legend_position(None)
918        .block(Block::bordered().title(format!(" {} · {} ", tx.t(def.title), figure)))
919        .x_axis(
920            Axis::default()
921                .bounds([-window_s, 0.0])
922                .labels(vec![
923                    format!("-{}", i18n::duration(window_s as u64)),
924                    tx.t("chart.now"),
925                ])
926                .style(th.dim()),
927        )
928        .y_axis(
929            Axis::default()
930                .bounds([low, high])
931                .labels(vec![
932                    fmt_unit(lang, def.unit, low),
933                    fmt_unit(lang, def.unit, mid),
934                    fmt_unit(lang, def.unit, high),
935                ])
936                .style(th.dim()),
937        );
938    f.render_widget(chart, area);
939}
940
941/// The vertical bounds: the window's own range with some air, or from zero to the largest value ever kept.
942fn scale(m: &Model, nodes: &[String], def: &cluster::ChartDef, values: &[f64]) -> (f64, f64) {
943    if m.cluster.monitor.fixed_scale {
944        let high = def
945            .lines
946            .iter()
947            .take(1)
948            .flat_map(|(series, _)| nodes.iter().map(move |id| (id, *series)))
949            .flat_map(|(id, series)| {
950                m.history
951                    .series(id, series)
952                    .map(|r| r.window(m.now, m.history.retention_ms()))
953                    .unwrap_or_default()
954            })
955            .map(|p| p.value)
956            .fold(f64::NEG_INFINITY, f64::max);
957        return (
958            0.0,
959            if high.is_finite() && high > 0.0 {
960                high
961            } else {
962                1.0
963            },
964        );
965    }
966    let low = values.iter().copied().fold(f64::INFINITY, f64::min);
967    let high = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
968    let pad = if (high - low).abs() < f64::EPSILON {
969        high.abs().max(1.0) * 0.1
970    } else {
971        (high - low) * 0.1
972    };
973    let floor = if low >= 0.0 {
974        (low - pad).max(0.0)
975    } else {
976        low - pad
977    };
978    (floor, high + pad)
979}