1use 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
29pub 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
97fn 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 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 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
400const STRIP_HEIGHT: u16 = 10;
402
403fn 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
417const STRIP_CHARTS: [usize; 3] = [0, 1, 3];
419
420fn 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
436pub 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
671pub 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
683fn 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
698fn 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 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 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
779fn 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
797fn 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 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
839fn 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 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
945fn 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}